Repository: namespacelabs/breakpoint Branch: main Commit: d8f06ac16053 Files: 71 Total size: 165.5 KB Directory structure: gitextract_v5sic7m5/ ├── .dockerignore ├── .github/ │ ├── slack-notification.json │ └── workflows/ │ ├── build.yml │ ├── checks.yml │ ├── goreleaser.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── api/ │ ├── private/ │ │ └── v1/ │ │ ├── configtype.go │ │ ├── service.pb.go │ │ ├── service.proto │ │ └── service_grpc.pb.go │ └── public/ │ └── v1/ │ ├── service.pb.go │ ├── service.proto │ ├── service_grpc.pb.go │ └── types.go ├── buf.gen.yaml ├── cmd/ │ ├── breakpoint/ │ │ ├── attach.go │ │ ├── extend.go │ │ ├── hold.go │ │ ├── main.go │ │ ├── resume.go │ │ ├── start.go │ │ ├── status.go │ │ └── wait.go │ └── rendezvous/ │ └── main.go ├── docs/ │ ├── CONTRIBUTING.md │ └── server-setup.md ├── examples/ │ └── wait.withslack.json ├── flake.nix ├── fly.toml ├── go.mod ├── go.sum └── pkg/ ├── README.md ├── bcontrol/ │ └── client.go ├── bgrpc/ │ └── bgrpc.go ├── blog/ │ └── blog.go ├── config/ │ └── config.go ├── execbackground/ │ ├── bg_unix.go │ └── bg_windows.go ├── github/ │ └── sshkeys.go ├── githuboidc/ │ ├── claims.go │ ├── gh.go │ └── verifier.go ├── httperrors/ │ └── httperrors.go ├── internalserver/ │ └── internalserver.go ├── jsonfile/ │ └── load.go ├── passthrough/ │ └── listener.go ├── quicgrpc/ │ └── grpccreds.go ├── quicnet/ │ ├── conn.go │ └── listener.go ├── quicproxy/ │ ├── proxyproto.go │ ├── rawproto.go │ ├── serve.go │ └── service.go ├── quicproxyclient/ │ └── client.go ├── sshd/ │ ├── keepalive.go │ ├── pty_unix.go │ ├── pty_windows.go │ ├── sftp.go │ └── sshd.go ├── tlscerts/ │ └── tlscerts.go ├── waiter/ │ ├── output.go │ ├── slackbot.go │ ├── template.go │ ├── template_test.go │ └── waiter.go └── webhook/ └── notifier.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ fly.toml ================================================ FILE: .github/slack-notification.json ================================================ { "url": "${SLACK_WEBHOOK_URL}", "payload": { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Workflow breakpoint started", "emoji": true } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Repository:* (${GITHUB_REF_NAME})" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Workflow:* ${GITHUB_WORKFLOW} ()" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*SSH:* `ssh -p ${BREAKPOINT_PORT} runner@${BREAKPOINT_HOST}`" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Expires:* in ${BREAKPOINT_TIME_LEFT} (${BREAKPOINT_EXPIRATION})" } }, { "type": "context", "elements": [ { "type": "plain_text", "text": "Actor: ${GITHUB_ACTOR}", "emoji": true } ] } ] } } ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: push: branches: - main workflow_dispatch: permissions: contents: read # Checkout the code packages: write # Push to GitHub registry env: IMAGE_NAME: rendezvous IMAGE_REPO: ghcr.io/${{ github.repository_owner }} VERSION: ${{ github.sha }} jobs: docker-build: name: Build with Docker runs-on: nscloud steps: - name: Checkout uses: actions/checkout@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Setup Buildx for Docker build uses: docker/setup-buildx-action@v2 - name: Docker build the Rendezvous server uses: docker/build-push-action@v4 with: context: . push: true tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} - name: Breakpoint on failure if: failure() uses: namespacelabs/breakpoint-action@v0 env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} with: duration: 30m authorized-users: hugosantos,n-g,htr slack-announce-channel: "#ci" ================================================ FILE: .github/workflows/checks.yml ================================================ name: Commit Checks on: pull_request: branches: - "*" push: branches: - main workflow_dispatch: permissions: contents: read # Checkout the code jobs: checks: name: Code Checks runs-on: nscloud-ubuntu-22.04-amd64-2x8 steps: - name: Checkout uses: actions/checkout@v3 - name: Install Go uses: actions/setup-go@v4 with: go-version: 'stable' - name: Check Go formatting run: go fmt ./... && git diff --exit-code - name: Check Go mod is tidy run: go mod tidy && git diff --exit-code - name: Check that Go builds run: | go build -o . ./cmd/... - name: Breakpoint on failure if: failure() uses: namespacelabs/breakpoint-action@v0 env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} with: duration: 30m authorized-users: hugosantos,n-g,htr slack-announce-channel: "#ci" ================================================ FILE: .github/workflows/goreleaser.yml ================================================ name: Release Binaries on: push: tags: ["v*"] jobs: goreleaser: runs-on: nscloud-ubuntu-24.04-amd64-4x8 permissions: contents: write steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: "1.20" - uses: goreleaser/goreleaser-action@v6 with: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Rendezvous Docker image on: release: types: [released] permissions: contents: read # Checkout the code packages: write # Push to GitHub registry env: IMAGE_NAME: rendezvous IMAGE_REPO: ghcr.io/${{ github.repository_owner }} VERSION: ${{ github.event.release.tag_name }} jobs: docker-release: name: Release Docker image ${{ github.event.release.tag_name }} runs-on: nscloud-ubuntu-22.04-amd64-2x8 steps: - name: Checkout uses: actions/checkout@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Setup Buildx for Docker build uses: namespacelabs/nscloud-setup-buildx-action@v0 - name: Docker build the Rendezvous server uses: docker/build-push-action@v4 with: context: . push: true tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} platforms: linux/amd64,linux/arm64 - name: Breakpoint on failure if: failure() uses: namespacelabs/breakpoint-action@v0 env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} with: duration: 30m authorized-users: hugosantos,n-g,htr slack-announce-channel: "#ci" ================================================ FILE: .gitignore ================================================ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work dist/ ================================================ FILE: .goreleaser.yaml ================================================ before: hooks: # You may remove this if you don't use go modules. - go mod tidy builds: - id: breakpoint main: ./cmd/breakpoint binary: breakpoint env: - CGO_ENABLED=0 goos: - linux - darwin - windows goarch: - amd64 - arm64 archives: - id: breakpoint builds: - breakpoint name_template: "breakpoint_{{ .Os }}_{{ .Arch }}" release: github: owner: namespacelabs name: breakpoint checksum: name_template: "checksums.txt" snapshot: name_template: "{{ incpatch .Version }}-next" changelog: sort: asc filters: exclude: - "^docs:" - "^test:" - "^nochangelog" - "^Merge pull request" - "^Merge branch" ================================================ FILE: Dockerfile ================================================ FROM golang:1.20-alpine AS builder WORKDIR /app COPY go.mod ./ COPY go.sum ./ RUN go mod download COPY . . RUN go build ./cmd/rendezvous FROM cgr.dev/chainguard/static COPY --from=builder /app/rendezvous /rendezvous CMD [ "/rendezvous" ] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ Breakpoint. Debug with SSH. Resume. [![Discord](https://img.shields.io/badge/Join-Namespace-blue?color=blue&label=Discord&logo=discord&logoColor=3eb0ff&style=flat-square)](https://discord.gg/DqMzDFR6Hc) [![Twitter Follow](https://img.shields.io/badge/Follow-Namespace_Labs-blue?logo=twitter&style=flat-square)](https://twitter.com/intent/follow?screen_name=namespacelabs) [![GitHub Actions](https://img.shields.io/badge/GitHub-Action-blue?logo=githubactions&style=flat-square)](https://github.com/namespacelabs/breakpoint-action) ![GitHub](https://img.shields.io/github/license/namespacelabs/breakpoint?color=blue&label=License&style=flat-square) ![Build](https://img.shields.io/github/actions/workflow/status/namespacelabs/breakpoint/build.yml?label=Build&style=flat-square) ![Checks](https://img.shields.io/github/actions/workflow/status/namespacelabs/breakpoint/checks.yml?label=Checks&style=flat-square) # Breakpoint Add breakpoints to CI (e.g. GitHub Action workflows): pause workflows, access the workflow with SSH, debug and resume executions. ## What is Breakpoint Have you ever wished you could have debugged an issue in CI (e.g. GitHub Actions), by SSHing to where your build or tests are running? Breakpoint helps you create breakpoints in CI: stop the execution of the workflow, and jump in to live debug as needed with SSH (without compromising end-to-end encryption). You can make changes, re-run commands, and resume the workflow as needed. Need more time? Just run `breakpoint extend` to extend your breakpoint duration. And it's 100% open-source (both client and server). > ℹ️ Workflows that have active breakpoints are still "running" and continue to count towards your total CI usage. ## Using Breakpoint Breakpoint loves GitHub Actions. You can use the [Breakpoint Action](https://github.com/namespacelabs/breakpoint-action) to add a breakpoint to a GitHub workflow; but most importantly, you can add breakpoints that only trigger when there's a failure in the workflow. The example below triggers the Breakpoint only if the previous step (i.e. `go test`) failed. When that happens, Breakpoint pauses the workflow for 30 minutes and allows SSH from GitHub users "jack123" and "alice321". ```yaml jobs: go-tests: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - name: Checkout uses: actions/checkout@v4 - name: Run Go tests runs: | go test ./... - name: Breakpoint if tests failed if: failure() uses: namespacelabs/breakpoint-action@v0 with: duration: 30m authorized-users: jack123, alice321 ``` When Breakpoint activates, it will output on a regular basis how much time left there is in the breakpoint, and which address to SSH to get to the workflow. ```bash ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ Breakpoint running until 2023-05-24T16:06:48+02:00 (29 minutes from now). │ │ │ │ Connect with: ssh -p 40812 runner@rendezvous.namespace.so │ │ │ └───────────────────────────────────────────────────────────────────────────┘ ``` You can now SSH the runner, re-run builds or tests, and even do changes. If you need more time, run `breakpoint extend` to extend the breakpoint duration by 30 more minutes (or extend by more with the `--for` flag). When you are done, you can end the breakpoint session with `breakpoint resume`. > [!TIP] > You can also run Breakpoint in the background, by adding `mode: background` to the actions inputs. \ > That way, you can connect to it at any time during your workflow > >
> Example >

> > ```yaml > - name: Breakpoint in the background > uses: namespacelabs/breakpoint-action@v0 > with: > mode: background > authorized-users: jack123, alice321 > ``` >

> > [More info](https://github.com/namespacelabs/breakpoint-action?tab=readme-ov-file#run-in-the-background) By default, the Breakpoint Action uses a shared `rendezvous` server provided by Namespace Labs for free. Even though a shared server is used, your SSH traffic is always _encrypted end-to-end_ (see Architecture). Check out the [Breakpoint Action](https://github.com/namespacelabs/breakpoint-action) for more details on what arguments you can set. ### Using the Breakpoint CLI to create a breakpoint To activate a breakpoint, you can run: ```bash $ breakpoint wait --config config.json ``` The config file can look like as follows: ```json { "endpoint": "rendezvous.namespace.so:5000", "shell": ["/bin/bash"], "allowed_ssh_users": ["runner"], "authorized_keys": [], "authorized_github_users": [""], "duration": "30m" } ``` The `wait` command will block the caller and print an SSH endpoint that you can connect to: ```bash ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ Breakpoint running until 2023-05-24T16:06:48+02:00 (29 minutes from now). │ │ │ │ Connect with: ssh -p 40812 runner@rendezvous.namespace.so │ │ │ └───────────────────────────────────────────────────────────────────────────┘ ``` Once you are logged into the SSH session, you can use breakpoint CLI to extend the breakpoint duration, or resume the workflow (i.e. exit the `wait`): - `breakpoint extend --for 60m`: extend the wait period for 30m more minutes - `breakpoint resume`: stops Breakpoint process and release the control flow to the caller of the `wait` command ## Architecture Breakpoint consists of two main components: `rendezvous` (where public connections are terminated) and `breakpoint`. When a breakpoint is created, the CLI blocks until an expiration time has passed. Meanwhile, it establishes a QUIC connection to `rendezvous`, which allocates a public endpoint (with a random port) that will be reverse proxied back to the running `breakpoint`; each connection then serves a SSH session (from a ssh service embedded in `breakpoint`). SSH sessions do not start new user sessions, and always run commands using the same uid as the parent `breakpoint wait` as well. The first QUIC stream `breakpoint -> rendezvous` is used for gRPC; `rendezvous` expects a `Register` stream in order to allocate an endpoint, and will serve that endpoint while the corresponding gRPC stream is active. Because the SSH session is established end-to-end, `rendezvous` is not capable of performing a man-in-the-middle attack. ![architecture](docs/imgs/Breakpoint%20high-level%20view.png) The CLI implements pausing by blocking the caller process. The command `breakpoint wait` blocks until either the user runs `breakpoint resume` or the wait-timer expires. The communication between the `wait` process and the CLI is implemented with gRPC. On receive a connection, `rendezvous` establishes a new QUIC stream over the same connection that was registered previously, in the direction `rendezvous -> breakpoint` and performs dumb TCP proxying over it, without the need of additional framing. The lack of additional framing in addition to QUIC's streams having independent control flow (i.e. no shared head of the line blocking), make QUIC a perfect solution for this type of reverse proxying (in fact, cloudflare uses similar techniques in Cloudflare Tunnel). ## Authentication The SSH service in `breakpoint` only accepts sessions from pre-referenced keys or public SSH keys configured by GitHub users. These are specified in the configuration file when the breakpoint is created (or as arguments to the GitHub action). You can specify GitHub usernames in the `github_usernames` config field. Breakpoint automatically fetches the SSH public keys from GitHub for these users. You can also specify the SSH keys directly via the `authorized_keys` field. The SSH service always spawns processes with the same uid as `breakpoint wait`, and by default accepts any requested username. This can be limited by setting the `allowed_ssh_users` configuration field. For example, the following `config.json` allows access to "jack123" and "alice321" GitHub users with a SSH user called "runner". ```json { "allowed_ssh_users": ["runner"], "authorized_github_users": ["jack123", "alice321"] } ``` ### GitHub-based authentication (via OIDC) `breakpoint` is able to request a fresh GitHub-emitted workflow identifying token, that it sends to `rendezvous`. `rendezvous` has the ability to verify these, and performs access control based on the repository where the invocation was originated. Even if no access control is enforced, repository information is logged by `rendezvous` if available. ## Using Namespace's shared Rendezvous Namespace Labs runs a public `rendezvous` server that is open to everyone. But you can also run your own (see below). Although `rendezvous` facilitates pushing bytes to workloads running in workers (which would otherwise not be able to offer services), the bytes it proxies are not cleartext. Breakpoint establishes end-to-end ssh sessions. To use the shared `rendezvous`, use the following endpoint: ```json { "endpoint": "rendezvous.namespace.so:5000" } ``` ## Running Rendezvous yourself See our [documentation](docs/server-setup.md) on how to run your own instance of `rendezvous`. ## Roadmap Here's a list of features that we'd to tackle but haven't gotten to yet. 1. Traffic rate limiting: neither the Rendezvous Server nor the Breakpoint client restrict network traffic that is proxied. So far this hasn't been an issue because GitHub runners themselves are network capped. 2. The Rendezvous Server does not implement a control and monitoring Web UI. 3. Neither the Rendezvous Server nor the Breakpoint client expose metrics. 4. The Breakpoint session does not automatically extend itself if an SSH connection is active. You need to explicitly extend the session with `breakpoint extend`. 5. Configurable ACLs on the Rendezvous Server to specify the list of repositories and organizations allowed to connect to the server. 6. Support for more authentication schemes between `breakpoint` and `rendezvous`. Breakpoint client and Rendezvous Server only support GitHub's OIDC-based authentication today. 7. Team and Organization authorization of users in Breakpoint client's SSH service (i.e. specifying a team or org rather than individual usernames). ## Contributions Breakpoint welcomes your help! We appreciate your time and effort. If you find an issue in Breakpoint or you see a missing feature, feel free to open an [Issue](https://github.com/namespacelabs/breakpoint/issues) on GitHub. Check out our [contribution guidelines](docs/CONTRIBUTING.md) for more details on how to develop Breakpoint. ## Join the Community If you have questions, ideas or feedback, chat with the team on our [Discord server](https://community.namespace.so/discord). ================================================ FILE: api/private/v1/configtype.go ================================================ package v1 type WaitConfig struct { Endpoint string `json:"endpoint"` Duration string `json:"duration"` AuthorizedKeys []string `json:"authorized_keys"` AuthorizedGithubUsers []string `json:"authorized_github_users"` Shell []string `json:"shell"` AllowedSSHUsers []string `json:"allowed_ssh_users"` Enable []string `json:"enable"` Webhooks []Webhook `json:"webhooks"` SlackBot *SlackBot `json:"slack_bot"` } type Webhook struct { URL string `json:"url"` Payload map[string]any `json:"payload"` } type SlackBot struct { Token string `json:"token"` Channel string `json:"channel"` } ================================================ FILE: api/private/v1/service.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.29.1 // protoc (unknown) // source: api/private/v1/service.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" emptypb "google.golang.org/protobuf/types/known/emptypb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type ExtendRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields WaitFor *durationpb.Duration `protobuf:"bytes,1,opt,name=wait_for,json=waitFor,proto3" json:"wait_for,omitempty"` } func (x *ExtendRequest) Reset() { *x = ExtendRequest{} if protoimpl.UnsafeEnabled { mi := &file_api_private_v1_service_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *ExtendRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ExtendRequest) ProtoMessage() {} func (x *ExtendRequest) ProtoReflect() protoreflect.Message { mi := &file_api_private_v1_service_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ExtendRequest.ProtoReflect.Descriptor instead. func (*ExtendRequest) Descriptor() ([]byte, []int) { return file_api_private_v1_service_proto_rawDescGZIP(), []int{0} } func (x *ExtendRequest) GetWaitFor() *durationpb.Duration { if x != nil { return x.WaitFor } return nil } type ExtendResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Expiration *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=expiration,proto3" json:"expiration,omitempty"` } func (x *ExtendResponse) Reset() { *x = ExtendResponse{} if protoimpl.UnsafeEnabled { mi := &file_api_private_v1_service_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *ExtendResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*ExtendResponse) ProtoMessage() {} func (x *ExtendResponse) ProtoReflect() protoreflect.Message { mi := &file_api_private_v1_service_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ExtendResponse.ProtoReflect.Descriptor instead. func (*ExtendResponse) Descriptor() ([]byte, []int) { return file_api_private_v1_service_proto_rawDescGZIP(), []int{1} } func (x *ExtendResponse) GetExpiration() *timestamppb.Timestamp { if x != nil { return x.Expiration } return nil } type StatusResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Expiration *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=expiration,proto3" json:"expiration,omitempty"` Endpoint string `protobuf:"bytes,2,opt,name=endpoint,proto3" json:"endpoint,omitempty"` NumConnections uint32 `protobuf:"varint,3,opt,name=num_connections,json=numConnections,proto3" json:"num_connections,omitempty"` } func (x *StatusResponse) Reset() { *x = StatusResponse{} if protoimpl.UnsafeEnabled { mi := &file_api_private_v1_service_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *StatusResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*StatusResponse) ProtoMessage() {} func (x *StatusResponse) ProtoReflect() protoreflect.Message { mi := &file_api_private_v1_service_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. func (*StatusResponse) Descriptor() ([]byte, []int) { return file_api_private_v1_service_proto_rawDescGZIP(), []int{2} } func (x *StatusResponse) GetExpiration() *timestamppb.Timestamp { if x != nil { return x.Expiration } return nil } func (x *StatusResponse) GetEndpoint() string { if x != nil { return x.Endpoint } return "" } func (x *StatusResponse) GetNumConnections() uint32 { if x != nil { return x.NumConnections } return 0 } var File_api_private_v1_service_proto protoreflect.FileDescriptor var file_api_private_v1_service_proto_rawDesc = []byte{ 0x0a, 0x1c, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x20, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x45, 0x0a, 0x0d, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x08, 0x77, 0x61, 0x69, 0x74, 0x5f, 0x66, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x77, 0x61, 0x69, 0x74, 0x46, 0x6f, 0x72, 0x22, 0x4c, 0x0a, 0x0e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 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, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x91, 0x01, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 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, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x6e, 0x75, 0x6d, 0x5f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x6e, 0x75, 0x6d, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0x8b, 0x02, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x38, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x6b, 0x0a, 0x06, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x12, 0x2f, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x30, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x2d, 0x5a, 0x2b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x64, 0x65, 0x76, 0x2f, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_api_private_v1_service_proto_rawDescOnce sync.Once file_api_private_v1_service_proto_rawDescData = file_api_private_v1_service_proto_rawDesc ) func file_api_private_v1_service_proto_rawDescGZIP() []byte { file_api_private_v1_service_proto_rawDescOnce.Do(func() { file_api_private_v1_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_private_v1_service_proto_rawDescData) }) return file_api_private_v1_service_proto_rawDescData } var file_api_private_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_api_private_v1_service_proto_goTypes = []interface{}{ (*ExtendRequest)(nil), // 0: namespacelabs.breakpoint.private.ExtendRequest (*ExtendResponse)(nil), // 1: namespacelabs.breakpoint.private.ExtendResponse (*StatusResponse)(nil), // 2: namespacelabs.breakpoint.private.StatusResponse (*durationpb.Duration)(nil), // 3: google.protobuf.Duration (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp (*emptypb.Empty)(nil), // 5: google.protobuf.Empty } var file_api_private_v1_service_proto_depIdxs = []int32{ 3, // 0: namespacelabs.breakpoint.private.ExtendRequest.wait_for:type_name -> google.protobuf.Duration 4, // 1: namespacelabs.breakpoint.private.ExtendResponse.expiration:type_name -> google.protobuf.Timestamp 4, // 2: namespacelabs.breakpoint.private.StatusResponse.expiration:type_name -> google.protobuf.Timestamp 5, // 3: namespacelabs.breakpoint.private.ControlService.Resume:input_type -> google.protobuf.Empty 0, // 4: namespacelabs.breakpoint.private.ControlService.Extend:input_type -> namespacelabs.breakpoint.private.ExtendRequest 5, // 5: namespacelabs.breakpoint.private.ControlService.Status:input_type -> google.protobuf.Empty 5, // 6: namespacelabs.breakpoint.private.ControlService.Resume:output_type -> google.protobuf.Empty 1, // 7: namespacelabs.breakpoint.private.ControlService.Extend:output_type -> namespacelabs.breakpoint.private.ExtendResponse 2, // 8: namespacelabs.breakpoint.private.ControlService.Status:output_type -> namespacelabs.breakpoint.private.StatusResponse 6, // [6:9] is the sub-list for method output_type 3, // [3:6] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension extendee 0, // [0:3] is the sub-list for field type_name } func init() { file_api_private_v1_service_proto_init() } func file_api_private_v1_service_proto_init() { if File_api_private_v1_service_proto != nil { return } if !protoimpl.UnsafeEnabled { file_api_private_v1_service_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ExtendRequest); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_api_private_v1_service_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ExtendResponse); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_api_private_v1_service_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StatusResponse); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_api_private_v1_service_proto_rawDesc, NumEnums: 0, NumMessages: 3, NumExtensions: 0, NumServices: 1, }, GoTypes: file_api_private_v1_service_proto_goTypes, DependencyIndexes: file_api_private_v1_service_proto_depIdxs, MessageInfos: file_api_private_v1_service_proto_msgTypes, }.Build() File_api_private_v1_service_proto = out.File file_api_private_v1_service_proto_rawDesc = nil file_api_private_v1_service_proto_goTypes = nil file_api_private_v1_service_proto_depIdxs = nil } ================================================ FILE: api/private/v1/service.proto ================================================ syntax = "proto3"; package namespacelabs.breakpoint.private; import "google/protobuf/duration.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; option go_package = "namespacelabs.dev/breakpoint/api/private/v1"; service ControlService { rpc Resume(google.protobuf.Empty) returns (google.protobuf.Empty); rpc Extend(ExtendRequest) returns (ExtendResponse); rpc Status(google.protobuf.Empty) returns (StatusResponse); } message ExtendRequest { google.protobuf.Duration wait_for = 1; } message ExtendResponse { google.protobuf.Timestamp expiration = 1; } message StatusResponse { google.protobuf.Timestamp expiration = 1; string endpoint = 2; uint32 num_connections = 3; } ================================================ FILE: api/private/v1/service_grpc.pb.go ================================================ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 // - protoc (unknown) // source: api/private/v1/service.proto package v1 import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" emptypb "google.golang.org/protobuf/types/known/emptypb" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.32.0 or later. const _ = grpc.SupportPackageIsVersion7 const ( ControlService_Resume_FullMethodName = "/namespacelabs.breakpoint.private.ControlService/Resume" ControlService_Extend_FullMethodName = "/namespacelabs.breakpoint.private.ControlService/Extend" ControlService_Status_FullMethodName = "/namespacelabs.breakpoint.private.ControlService/Status" ) // ControlServiceClient is the client API for ControlService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ControlServiceClient interface { Resume(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) Extend(ctx context.Context, in *ExtendRequest, opts ...grpc.CallOption) (*ExtendResponse, error) Status(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*StatusResponse, error) } type controlServiceClient struct { cc grpc.ClientConnInterface } func NewControlServiceClient(cc grpc.ClientConnInterface) ControlServiceClient { return &controlServiceClient{cc} } func (c *controlServiceClient) Resume(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { out := new(emptypb.Empty) err := c.cc.Invoke(ctx, ControlService_Resume_FullMethodName, in, out, opts...) if err != nil { return nil, err } return out, nil } func (c *controlServiceClient) Extend(ctx context.Context, in *ExtendRequest, opts ...grpc.CallOption) (*ExtendResponse, error) { out := new(ExtendResponse) err := c.cc.Invoke(ctx, ControlService_Extend_FullMethodName, in, out, opts...) if err != nil { return nil, err } return out, nil } func (c *controlServiceClient) Status(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*StatusResponse, error) { out := new(StatusResponse) err := c.cc.Invoke(ctx, ControlService_Status_FullMethodName, in, out, opts...) if err != nil { return nil, err } return out, nil } // ControlServiceServer is the server API for ControlService service. // All implementations must embed UnimplementedControlServiceServer // for forward compatibility type ControlServiceServer interface { Resume(context.Context, *emptypb.Empty) (*emptypb.Empty, error) Extend(context.Context, *ExtendRequest) (*ExtendResponse, error) Status(context.Context, *emptypb.Empty) (*StatusResponse, error) mustEmbedUnimplementedControlServiceServer() } // UnimplementedControlServiceServer must be embedded to have forward compatible implementations. type UnimplementedControlServiceServer struct { } func (UnimplementedControlServiceServer) Resume(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method Resume not implemented") } func (UnimplementedControlServiceServer) Extend(context.Context, *ExtendRequest) (*ExtendResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Extend not implemented") } func (UnimplementedControlServiceServer) Status(context.Context, *emptypb.Empty) (*StatusResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Status not implemented") } func (UnimplementedControlServiceServer) mustEmbedUnimplementedControlServiceServer() {} // UnsafeControlServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ControlServiceServer will // result in compilation errors. type UnsafeControlServiceServer interface { mustEmbedUnimplementedControlServiceServer() } func RegisterControlServiceServer(s grpc.ServiceRegistrar, srv ControlServiceServer) { s.RegisterService(&ControlService_ServiceDesc, srv) } func _ControlService_Resume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(emptypb.Empty) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(ControlServiceServer).Resume(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: ControlService_Resume_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ControlServiceServer).Resume(ctx, req.(*emptypb.Empty)) } return interceptor(ctx, in, info, handler) } func _ControlService_Extend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ExtendRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(ControlServiceServer).Extend(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: ControlService_Extend_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ControlServiceServer).Extend(ctx, req.(*ExtendRequest)) } return interceptor(ctx, in, info, handler) } func _ControlService_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(emptypb.Empty) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(ControlServiceServer).Status(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: ControlService_Status_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(ControlServiceServer).Status(ctx, req.(*emptypb.Empty)) } return interceptor(ctx, in, info, handler) } // ControlService_ServiceDesc is the grpc.ServiceDesc for ControlService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var ControlService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "namespacelabs.breakpoint.private.ControlService", HandlerType: (*ControlServiceServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "Resume", Handler: _ControlService_Resume_Handler, }, { MethodName: "Extend", Handler: _ControlService_Extend_Handler, }, { MethodName: "Status", Handler: _ControlService_Status_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "api/private/v1/service.proto", } ================================================ FILE: api/public/v1/service.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.29.1 // protoc (unknown) // source: api/public/v1/service.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type RegisterRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields } func (x *RegisterRequest) Reset() { *x = RegisterRequest{} if protoimpl.UnsafeEnabled { mi := &file_api_public_v1_service_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *RegisterRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*RegisterRequest) ProtoMessage() {} func (x *RegisterRequest) ProtoReflect() protoreflect.Message { mi := &file_api_public_v1_service_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead. func (*RegisterRequest) Descriptor() ([]byte, []int) { return file_api_public_v1_service_proto_rawDescGZIP(), []int{0} } type RegisterResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Endpoint string `protobuf:"bytes,1,opt,name=endpoint,proto3" json:"endpoint,omitempty"` // Connection endpoint, e.g.
: } func (x *RegisterResponse) Reset() { *x = RegisterResponse{} if protoimpl.UnsafeEnabled { mi := &file_api_public_v1_service_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *RegisterResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*RegisterResponse) ProtoMessage() {} func (x *RegisterResponse) ProtoReflect() protoreflect.Message { mi := &file_api_public_v1_service_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead. func (*RegisterResponse) Descriptor() ([]byte, []int) { return file_api_public_v1_service_proto_rawDescGZIP(), []int{1} } func (x *RegisterResponse) GetEndpoint() string { if x != nil { return x.Endpoint } return "" } var File_api_public_v1_service_proto protoreflect.FileDescriptor var file_api_public_v1_service_proto_rawDesc = []byte{ 0x0a, 0x1b, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x18, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x22, 0x11, 0x0a, 0x0f, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x2e, 0x0a, 0x10, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x32, 0x73, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x63, 0x0a, 0x08, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x12, 0x29, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x42, 0x2c, 0x5a, 0x2a, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x64, 0x65, 0x76, 0x2f, 0x62, 0x72, 0x65, 0x61, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_api_public_v1_service_proto_rawDescOnce sync.Once file_api_public_v1_service_proto_rawDescData = file_api_public_v1_service_proto_rawDesc ) func file_api_public_v1_service_proto_rawDescGZIP() []byte { file_api_public_v1_service_proto_rawDescOnce.Do(func() { file_api_public_v1_service_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_public_v1_service_proto_rawDescData) }) return file_api_public_v1_service_proto_rawDescData } var file_api_public_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_api_public_v1_service_proto_goTypes = []interface{}{ (*RegisterRequest)(nil), // 0: namespacelabs.breakpoint.RegisterRequest (*RegisterResponse)(nil), // 1: namespacelabs.breakpoint.RegisterResponse } var file_api_public_v1_service_proto_depIdxs = []int32{ 0, // 0: namespacelabs.breakpoint.ProxyService.Register:input_type -> namespacelabs.breakpoint.RegisterRequest 1, // 1: namespacelabs.breakpoint.ProxyService.Register:output_type -> namespacelabs.breakpoint.RegisterResponse 1, // [1:2] is the sub-list for method output_type 0, // [0:1] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_api_public_v1_service_proto_init() } func file_api_public_v1_service_proto_init() { if File_api_public_v1_service_proto != nil { return } if !protoimpl.UnsafeEnabled { file_api_public_v1_service_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*RegisterRequest); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_api_public_v1_service_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*RegisterResponse); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_api_public_v1_service_proto_rawDesc, NumEnums: 0, NumMessages: 2, NumExtensions: 0, NumServices: 1, }, GoTypes: file_api_public_v1_service_proto_goTypes, DependencyIndexes: file_api_public_v1_service_proto_depIdxs, MessageInfos: file_api_public_v1_service_proto_msgTypes, }.Build() File_api_public_v1_service_proto = out.File file_api_public_v1_service_proto_rawDesc = nil file_api_public_v1_service_proto_goTypes = nil file_api_public_v1_service_proto_depIdxs = nil } ================================================ FILE: api/public/v1/service.proto ================================================ syntax = "proto3"; package namespacelabs.breakpoint; option go_package = "namespacelabs.dev/breakpoint/api/public/v1"; service ProxyService { // The reverse tunnel is active for as long as this stream over a quic connection is active. rpc Register(RegisterRequest) returns (stream RegisterResponse); } message RegisterRequest {} message RegisterResponse { string endpoint = 1; // Connection endpoint, e.g.
: } ================================================ FILE: api/public/v1/service_grpc.pb.go ================================================ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 // - protoc (unknown) // source: api/public/v1/service.proto package v1 import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.32.0 or later. const _ = grpc.SupportPackageIsVersion7 const ( ProxyService_Register_FullMethodName = "/namespacelabs.breakpoint.ProxyService/Register" ) // ProxyServiceClient is the client API for ProxyService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ProxyServiceClient interface { // The reverse tunnel is active for as long as this stream over a quic connection is active. Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (ProxyService_RegisterClient, error) } type proxyServiceClient struct { cc grpc.ClientConnInterface } func NewProxyServiceClient(cc grpc.ClientConnInterface) ProxyServiceClient { return &proxyServiceClient{cc} } func (c *proxyServiceClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (ProxyService_RegisterClient, error) { stream, err := c.cc.NewStream(ctx, &ProxyService_ServiceDesc.Streams[0], ProxyService_Register_FullMethodName, opts...) if err != nil { return nil, err } x := &proxyServiceRegisterClient{stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } if err := x.ClientStream.CloseSend(); err != nil { return nil, err } return x, nil } type ProxyService_RegisterClient interface { Recv() (*RegisterResponse, error) grpc.ClientStream } type proxyServiceRegisterClient struct { grpc.ClientStream } func (x *proxyServiceRegisterClient) Recv() (*RegisterResponse, error) { m := new(RegisterResponse) if err := x.ClientStream.RecvMsg(m); err != nil { return nil, err } return m, nil } // ProxyServiceServer is the server API for ProxyService service. // All implementations must embed UnimplementedProxyServiceServer // for forward compatibility type ProxyServiceServer interface { // The reverse tunnel is active for as long as this stream over a quic connection is active. Register(*RegisterRequest, ProxyService_RegisterServer) error mustEmbedUnimplementedProxyServiceServer() } // UnimplementedProxyServiceServer must be embedded to have forward compatible implementations. type UnimplementedProxyServiceServer struct { } func (UnimplementedProxyServiceServer) Register(*RegisterRequest, ProxyService_RegisterServer) error { return status.Errorf(codes.Unimplemented, "method Register not implemented") } func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {} // UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ProxyServiceServer will // result in compilation errors. type UnsafeProxyServiceServer interface { mustEmbedUnimplementedProxyServiceServer() } func RegisterProxyServiceServer(s grpc.ServiceRegistrar, srv ProxyServiceServer) { s.RegisterService(&ProxyService_ServiceDesc, srv) } func _ProxyService_Register_Handler(srv interface{}, stream grpc.ServerStream) error { m := new(RegisterRequest) if err := stream.RecvMsg(m); err != nil { return err } return srv.(ProxyServiceServer).Register(m, &proxyServiceRegisterServer{stream}) } type ProxyService_RegisterServer interface { Send(*RegisterResponse) error grpc.ServerStream } type proxyServiceRegisterServer struct { grpc.ServerStream } func (x *proxyServiceRegisterServer) Send(m *RegisterResponse) error { return x.ServerStream.SendMsg(m) } // ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var ProxyService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "namespacelabs.breakpoint.ProxyService", HandlerType: (*ProxyServiceServer)(nil), Methods: []grpc.MethodDesc{}, Streams: []grpc.StreamDesc{ { StreamName: "Register", Handler: _ProxyService_Register_Handler, ServerStreams: true, }, }, Metadata: "api/public/v1/service.proto", } ================================================ FILE: api/public/v1/types.go ================================================ package v1 const ( QuicProto = "breakpoint-grpc" GitHubOIDCTokenHeader = "x-breakpoint-github-oidc-token" GitHubOIDCAudience = "namespacelabs.dev/breakpoint" ) ================================================ FILE: buf.gen.yaml ================================================ version: v1 plugins: - plugin: go out: . opt: paths=source_relative - plugin: go-grpc out: . opt: paths=source_relative ================================================ FILE: cmd/breakpoint/attach.go ================================================ package main import ( "errors" "net" "github.com/rs/zerolog" "github.com/spf13/cobra" "inet.af/tcpproxy" "namespacelabs.dev/breakpoint/pkg/quicproxyclient" ) func newAttachCmd() *cobra.Command { cmd := &cobra.Command{ Use: "attach", } endpoint := cmd.Flags().String("endpoint", "", "The address of the server.") target := cmd.Flags().String("target", "", "Where to connect to.") cmd.RunE = func(cmd *cobra.Command, args []string) error { if *endpoint == "" { return errors.New("--endpoint is required") } if *target == "" { return errors.New("--target is required") } return quicproxyclient.Serve(cmd.Context(), *endpoint, nil, quicproxyclient.Handlers{ OnAllocation: func(endpoint string) { zerolog.Ctx(cmd.Context()).Info().Str("endpoint", endpoint).Msg("Got allocation") }, Proxy: func(conn net.Conn) error { zerolog.Ctx(cmd.Context()).Info().Str("target", *target).Msg("handling reverse proxy") go tcpproxy.To(*target).HandleConn(conn) return nil }, }) } return cmd } func init() { rootCmd.AddCommand(newAttachCmd()) } ================================================ FILE: cmd/breakpoint/extend.go ================================================ package main import ( "fmt" "time" "github.com/spf13/cobra" "google.golang.org/protobuf/types/known/durationpb" pb "namespacelabs.dev/breakpoint/api/private/v1" "namespacelabs.dev/breakpoint/pkg/bcontrol" "namespacelabs.dev/breakpoint/pkg/waiter" "github.com/dustin/go-humanize" ) func newExtendCmd() *cobra.Command { cmd := &cobra.Command{ Use: "extend", Short: "Extend the breakpoint duration.", } extendWaitFor := cmd.Flags().Duration("for", time.Minute*30, "How much to extend the breakpoint by.") extendWaitDuration := cmd.Flags().Duration("duration", 0, "Alias of --for") cmd.MarkFlagsMutuallyExclusive("duration", "for") cmd.RunE = func(cmd *cobra.Command, args []string) error { duration := *extendWaitDuration if *extendWaitDuration == 0 { duration = *extendWaitFor } if duration <= 0 { return fmt.Errorf("duration must be positive") } clt, conn, err := bcontrol.Connect(cmd.Context()) if err != nil { return err } defer conn.Close() resp, err := clt.Extend(cmd.Context(), &pb.ExtendRequest{ WaitFor: durationpb.New(duration), }) if err != nil { return err } expiration := resp.Expiration.AsTime() fmt.Printf("Breakpoint now expires at %s (%s)\n", expiration.Format(waiter.Stamp), humanize.Time(expiration)) return nil } return cmd } func init() { rootCmd.AddCommand(newExtendCmd()) } ================================================ FILE: cmd/breakpoint/hold.go ================================================ package main import ( "context" "fmt" "os" "time" "github.com/dustin/go-humanize" "github.com/spf13/cobra" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/emptypb" v1 "namespacelabs.dev/breakpoint/api/private/v1" "namespacelabs.dev/breakpoint/pkg/bcontrol" "namespacelabs.dev/breakpoint/pkg/waiter" ) func init() { rootCmd.AddCommand(newHoldCmd()) } const ( extendBy = 30 * time.Second ) func newHoldCmd() *cobra.Command { cmd := &cobra.Command{ Use: "hold", Short: "Holds until a breakpoint is finished or for a certain amount of time.", } holdFor := cmd.Flags().Duration("for", time.Minute*30, "How much to extend the breakpoint by.") holdDuration := cmd.Flags().Duration("duration", 0, "Alias of --for") shouldHoldWhileConnected := cmd.Flags().Bool("while-connected", false, "Keep holding while there are active connections, even after duration has passed") stopWhenDone := cmd.Flags().Bool("stop", false, "Stop the breakpoint server after holding") cmd.MarkFlagsMutuallyExclusive("duration", "for", "while-connected") cmd.RunE = func(cmd *cobra.Command, args []string) error { duration := *holdDuration if *holdDuration == 0 { duration = *holdFor } ctx := cmd.Context() if *shouldHoldWhileConnected { if err := holdWhileConnected(ctx); err != nil { return err } } else { if err := holdForDuration(ctx, duration); err != nil { return err } } if *stopWhenDone { if err := stopBreakpoint(ctx); err != nil { fmt.Printf("Failed to stop breakpoint: %v\n", err) } else { fmt.Printf("Stopped breakpoint\n") } } return nil } return cmd } func holdForDuration(ctx context.Context, duration time.Duration) error { if duration <= 0 { return fmt.Errorf("duration must be positive") } status, err := getStatus(ctx) if err != nil { return err } waiter.PrintConnectionInfo(status.Endpoint, status.Expiration.AsTime(), os.Stderr) fmt.Printf("Holding until %s\n", humanize.Time(time.Now().Add(duration))) timer := time.NewTimer(duration) select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func holdWhileConnected(ctx context.Context) error { clt, conn, err := bcontrol.Connect(ctx) if err != nil { return err } defer conn.Close() status, err := clt.Status(ctx, &emptypb.Empty{}) if err != nil { return fmt.Errorf("unable to fetch breakpoint status, is breakpoint running") } if status.GetNumConnections() < 1 { fmt.Printf("No active connections, exiting\n") return nil } waiter.PrintConnectionInfo(status.Endpoint, status.Expiration.AsTime(), os.Stderr) tickDuration := 5 * time.Second ticker := time.NewTicker(tickDuration) defer ticker.Stop() fmt.Printf("Waiting until breakpoint has no active connections\n") for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: status, err := clt.Status(ctx, &emptypb.Empty{}) if err != nil { return fmt.Errorf("unable to fetch breakpoint status, assuming no longer running") } expiration := status.GetExpiration().AsTime() if !expiration.IsZero() && time.Now().Add(2*tickDuration).After(expiration) { tryExtendBreakpoint(ctx, expiration, clt) } if status.GetNumConnections() > 0 { fmt.Printf("Active connections: %d, waiting\n", status.GetNumConnections()) continue } fmt.Printf("No active connections, exiting\n") return nil } } } func tryExtendBreakpoint(ctx context.Context, currentExpiration time.Time, clt v1.ControlServiceClient) { fmt.Printf("Breakpoint expiring %s, extending by %s\n", humanize.Time(currentExpiration), extendBy) ret, err := clt.Extend(ctx, &v1.ExtendRequest{ WaitFor: durationpb.New(extendBy), }) if err != nil { fmt.Printf("Unable to extend breakpoint: %v\n", err) } fmt.Printf("Breakpoint now expires %s\n", humanize.Time(ret.GetExpiration().AsTime())) } func stopBreakpoint(ctx context.Context) error { clt, conn, err := bcontrol.Connect(ctx) if err != nil { return err } defer conn.Close() _, err = clt.Resume(ctx, &emptypb.Empty{}) return err } ================================================ FILE: cmd/breakpoint/main.go ================================================ package main import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/spf13/cobra" "namespacelabs.dev/breakpoint/pkg/blog" ) var rootCmd = &cobra.Command{ Use: "breakpoint", Short: `Add breakpoints to CI workflows.`, } func main() { // This is the only control we have available. os.Setenv("QUIC_GO_DISABLE_RECEIVE_BUFFER_WARNING", "true") l := blog.New() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() err := rootCmd.ExecuteContext(l.WithContext(ctx)) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ================================================ FILE: cmd/breakpoint/resume.go ================================================ package main import ( "fmt" "github.com/spf13/cobra" "google.golang.org/protobuf/types/known/emptypb" "namespacelabs.dev/breakpoint/pkg/bcontrol" ) func newResumeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "resume", Short: "Resume the workflow execution.", } cmd.RunE = func(cmd *cobra.Command, args []string) error { clt, conn, err := bcontrol.Connect(cmd.Context()) if err != nil { return err } defer conn.Close() if _, err := clt.Resume(cmd.Context(), &emptypb.Empty{}); err != nil { return err } fmt.Printf("Breakpoint removed, workflow resuming!\n") return nil } return cmd } func init() { rootCmd.AddCommand(newResumeCmd()) } ================================================ FILE: cmd/breakpoint/start.go ================================================ package main import ( "context" "errors" "fmt" "os" "os/exec" "time" "github.com/spf13/cobra" "google.golang.org/protobuf/types/known/emptypb" v1 "namespacelabs.dev/breakpoint/api/private/v1" "namespacelabs.dev/breakpoint/pkg/bcontrol" "namespacelabs.dev/breakpoint/pkg/execbackground" "namespacelabs.dev/breakpoint/pkg/waiter" ) func init() { rootCmd.AddCommand(newStartCmd()) } func newStartCmd() *cobra.Command { cmd := &cobra.Command{ Use: "start", Short: "Starts breakpoint in the background", } configPath := cmd.Flags().String("config", "", "Path to the configuration file.") cmd.RunE = func(cmd *cobra.Command, args []string) error { if *configPath == "" { return errors.New("--config is required") } procArgs := []string{"wait", "--config", *configPath} proc := exec.Command(os.Args[0], procArgs...) execbackground.SetCreateSession(proc) if err := proc.Start(); err != nil { return fmt.Errorf("failed to start background process: %w", err) } pid := proc.Process.Pid fmt.Fprintf(os.Stderr, "Breakpoint starting in background (PID: %d)\n", pid) status, err := waitForReady(cmd.Context(), 5*time.Second) if err != nil { _ = proc.Process.Kill() return err } if err := proc.Process.Release(); err != nil { return err } waiter.PrintConnectionInfo(status.Endpoint, status.GetExpiration().AsTime(), os.Stderr) return nil } return cmd } func waitForReady(ctx context.Context, timeoutDuration time.Duration) (*v1.StatusResponse, error) { // Check for file existence with timeout timeout := time.After(timeoutDuration) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return nil, ctx.Err() case <-timeout: return nil, fmt.Errorf("breakpoint didn't start in time") case <-ticker.C: status, err := getStatus(ctx) if err != nil { continue } if status.GetEndpoint() != "" { return status, nil } } } } func getStatus(ctx context.Context) (*v1.StatusResponse, error) { clt, conn, err := bcontrol.Connect(ctx) if err != nil { return nil, err } defer conn.Close() status, err := clt.Status(ctx, &emptypb.Empty{}) if err != nil { return nil, err } return status, nil } ================================================ FILE: cmd/breakpoint/status.go ================================================ package main import ( "fmt" "os" "github.com/spf13/cobra" "google.golang.org/protobuf/types/known/emptypb" "namespacelabs.dev/breakpoint/pkg/bcontrol" "namespacelabs.dev/breakpoint/pkg/waiter" ) func init() { rootCmd.AddCommand(newStatusCmd()) } func newStatusCmd() *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "Get the current status of breakpoint", } cmd.RunE = func(cmd *cobra.Command, args []string) error { clt, conn, err := bcontrol.Connect(cmd.Context()) if err != nil { fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stdout, "Unable to connect to breakpoint control server, is breakpoint running?") os.Exit(1) return nil } defer conn.Close() status, err := clt.Status(cmd.Context(), &emptypb.Empty{}) if err != nil { fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stdout, "Unable to retrieve status from breakpoint control server, is breakpoint running?") os.Exit(1) return nil } waiter.PrintConnectionInfo(status.Endpoint, status.Expiration.AsTime(), os.Stdout) fmt.Fprintf(os.Stdout, "\nActive connections: %d\n", status.GetNumConnections()) return nil } return cmd } ================================================ FILE: cmd/breakpoint/wait.go ================================================ package main import ( "context" "errors" "fmt" "io" "os" "github.com/dustin/go-humanize" "github.com/muesli/reflow/wordwrap" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" "namespacelabs.dev/breakpoint/pkg/config" "namespacelabs.dev/breakpoint/pkg/internalserver" "namespacelabs.dev/breakpoint/pkg/passthrough" "namespacelabs.dev/breakpoint/pkg/quicproxyclient" "namespacelabs.dev/breakpoint/pkg/sshd" "namespacelabs.dev/breakpoint/pkg/waiter" ) func init() { rootCmd.AddCommand(newWaitCmd()) } func newWaitCmd() *cobra.Command { cmd := &cobra.Command{ Use: "wait", Short: "Blocks for the duration of the breakpoint", } configPath := cmd.Flags().String("config", "", "Path to the configuration file.") cmd.RunE = func(cmd *cobra.Command, args []string) error { if *configPath == "" { return errors.New("--config is required") } ctx := cmd.Context() cfg, err := config.LoadConfig(ctx, *configPath) if err != nil { return err } mopts := waiter.ManagerOpts{ InitialDur: cfg.ParsedDuration, Webhooks: cfg.Webhooks, } if cfg.SlackBot != nil { mopts.SlackBots = append(mopts.SlackBots, *cfg.SlackBot) } mgr, ctx := waiter.NewManager(ctx, mopts) sshd, err := sshd.MakeServer(ctx, sshd.SSHServerOpts{ Shell: cfg.Shell, AuthorizedKeys: cfg.AllKeys, AllowedUsers: cfg.AllowedSSHUsers, Env: os.Environ(), InteractiveMOTD: func(w io.Writer) { ww := wordwrap.NewWriter(80) fmt.Fprintln(ww) fmt.Fprintf(ww, "Welcome to a breakpoint-provided remote shell.\n") fmt.Fprintln(ww) fmt.Fprintf(ww, "This breakpoint will expire %s.\n", humanize.Time(mgr.Expiration())) fmt.Fprintln(ww) fmt.Fprintf(ww, "The following additional commands are available:\n\n") fmt.Fprintf(ww, " - `breakpoint extend` to extend the breakpoint duration.\n") fmt.Fprintf(ww, " - `breakpoint resume` to resume immediately.\n") _ = ww.Close() _, _ = w.Write(ww.Bytes()) }, }) if err != nil { return err } mgr.SetConnectionCountCallback(sshd.NumConnections) eg, ctx := errgroup.WithContext(ctx) pl := passthrough.NewListener(ctx, dummyAddr{}) eg.Go(func() error { return sshd.Server.Serve(pl) }) eg.Go(func() error { defer pl.Close() return quicproxyclient.Serve(ctx, cfg.Endpoint, cfg.RegisterMetadata, quicproxyclient.Handlers{ OnAllocation: func(endpoint string) { mgr.SetEndpoint(endpoint) }, Proxy: pl.Offer, }) }) eg.Go(func() error { return internalserver.ListenAndServe(ctx, mgr) }) eg.Go(func() error { return mgr.Wait() }) return cancelIsOK(eg.Wait()) } return cmd } func cancelIsOK(err error) error { if errors.Is(err, context.Canceled) { return nil } return err } type dummyAddr struct{} func (dummyAddr) Network() string { return "internal" } func (dummyAddr) String() string { return "quic-revproxy" } ================================================ FILE: cmd/rendezvous/main.go ================================================ package main import ( "context" "encoding/json" "errors" "flag" "fmt" "log" "net" "net/http" "net/netip" "os" "strings" "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" "namespacelabs.dev/breakpoint/pkg/blog" "namespacelabs.dev/breakpoint/pkg/quicproxy" "namespacelabs.dev/breakpoint/pkg/tlscerts" ) var ( listenOn = flag.String("l", "", "The address:port to listen on.") publicAddress = flag.String("pub", "", "If unset, defaults to listen address.") subjectDomains = flag.String("sub", "", "Attaches the specified domain names as TLS cert subjects.") frontend = flag.String("frontend", "", "If specified, configures the frontend (in JSON).") httpPort = flag.Int("http_port", 10020, "Where we listen on HTTP.") enableGitHubOIDC = flag.Bool("validate_github_oidc", false, "Validate GitHub OIDC tokens.") redirectTarget = flag.String("redirect_target", "https://github.com/namespacelabs/breakpoint", "Where to redirect users to when accessed via HTTP.") ) type frontendConfig struct { Kind string `json:"kind"` PortStart int `json:"port_start"` PortEnd int `json:"port_end"` PortListen int `json:"listen_port"` } func main() { flag.Parse() var fcfg frontendConfig if frontendData := flagOrEnv("PROXY_FRONTEND", *frontend); frontendData != "" { if err := json.Unmarshal([]byte(frontendData), &fcfg); err != nil { log.Fatal(err) } } var domains []string if val := flagOrEnv("PROXY_DOMAINS", *subjectDomains); len(val) > 0 { domains = strings.Split(val, ",") } if err := run(Config{ ListenAddr: flagOrEnv("PROXY_LISTEN", *listenOn), HttpPort: *httpPort, FrontendConfig: fcfg, PublicAddr: flagOrEnv("PROXY_PUBLIC", *publicAddress), Domains: domains, EnableGitHubOIDC: flagOrEnvBool("PROXY_VALIDATE_GITHUB_OIDC", *enableGitHubOIDC), RedirectURL: *redirectTarget, }); err != nil { log.Fatal(err) } } func flagOrEnv(env, flag string) string { if flag != "" { return flag } return os.Getenv(env) } func flagOrEnvBool(env string, flag bool) bool { return flag || os.Getenv(env) == "true" || os.Getenv(env) == "1" } type Config struct { ListenAddr string HttpPort int FrontendConfig frontendConfig PublicAddr string Domains []string EnableGitHubOIDC bool RedirectURL string } func run(opts Config) error { if opts.ListenAddr == "" { return errors.New("-l or PROXY_LISTEN is required") } if opts.PublicAddr == "" { addrport, err := netip.ParseAddrPort(opts.ListenAddr) if err != nil { return err } opts.PublicAddr = addrport.Addr().String() } subjects := tlscerts.Subjects{ DNSNames: opts.Domains, } if addr, err := netip.ParseAddr(opts.PublicAddr); err == nil { if !addr.IsUnspecified() { subjects.IPAddresses = append(subjects.IPAddresses, net.IP(addr.AsSlice())) } } else { if !slices.Contains(subjects.DNSNames, opts.PublicAddr) { subjects.DNSNames = append(subjects.DNSNames, opts.PublicAddr) } } frontend := makeFrontend(opts.FrontendConfig, opts.PublicAddr) l := blog.New() ctx := l.WithContext(context.Background()) proxy, err := quicproxy.NewServer(ctx, quicproxy.ServerOpts{ ProxyFrontend: frontend, ListenAddr: opts.ListenAddr, Subjects: subjects, EnableGitHubOIDC: opts.EnableGitHubOIDC, }) if err != nil { return err } eg, ctx := errgroup.WithContext(ctx) eg.Go(func() error { return frontend.ListenAndServe(ctx) }) eg.Go(func() error { return proxy.Serve(ctx) }) eg.Go(func() error { h := http.NewServeMux() h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Location", opts.RedirectURL) w.WriteHeader(http.StatusTemporaryRedirect) fmt.Fprintf(w, "Heading over to %s", opts.RedirectURL, opts.RedirectURL) })) return http.ListenAndServe(fmt.Sprintf(":%d", opts.HttpPort), h) }) return eg.Wait() } func makeFrontend(fcfg frontendConfig, pub string) quicproxy.ProxyFrontend { switch fcfg.Kind { case "proxy_proto": return &quicproxy.ProxyProtoFrontend{ ListenPort: fcfg.PortListen, PortStart: fcfg.PortStart, PortEnd: fcfg.PortEnd, PublicAddr: pub, } default: return quicproxy.RawFrontend{ PublicAddr: pub, } } } ================================================ FILE: docs/CONTRIBUTING.md ================================================ # Contributing to Breakpoint ## Where to Start You can find good issues to tackle with labels [`good first issue`](https://github.com/namespacelabs/breakpoint/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and [`help wanted`](https://github.com/namespacelabs/breakpoint/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22). ## Issues tracking and Pull Requests We don't enforce any rigid contributing procedure. We appreciate you spending time improving `breakpoint`! If in doubt, please open a [new Issue](https://github.com/namespacelabs/breakpoint/issues/new) on GitHub. One of the maintainers will reach out soon, and you can discuss the next steps with them. Please include relevant GitHub Issues in the PR message when opening a Pull Request. ## Development Developing `breakpoint` requires `nix` and optionally `docker`. We use `nix` to ensure reproducible development flow: it guarantees the identical versions of dependencies and tools. While `docker` is required only if you plan to build the Docker image of the Rendezvous server. Follow the instructions to install them for your operating system: - [Install nix](https://github.com/DeterminateSystems/nix-installer) - Docker: [Docker engine](https://docs.docker.com/engine/install/) or [OrbStack](https://docs.docker.com/engine/install/) When `nix` is installed, you can: - Run `nix develop` to enter a shell with every dependency pre-setup (e.g. Go, `buf`, etc.) - Use the "nix environment selector" VSCode extension to apply a nix environment in VSCode. ### Building Compiling the Go binaries: ```bash $ go build -o . ./cmd/... # Binaries available in the current working directory $ ls breakpoint; ls rendezvous; ``` Installing the Go binaries: ```bash $ go install ./cmd/... # Binaries installed in $GOPATH $ which breakpoint; which rendezvous; ``` Building the Docker image of Rendezvous server: ```bash $ docker build . -t rendezvous:latest ``` ### Protos Breakpoint uses gRPC and protos to implement both internal and public API. Internal API is used between the `breakpoint wait` process and the rest of CLI commands. The public API is provided by the `rendezvous` server to accept incoming `breakpoint` registrations. Whenever you change the protos definition under the [`api/`](../api) folder, then you must also regenerate the Go code: ```bash $ buf generate ``` This will add changes to the Go files under the [`api/`](../api) folder. Include them in your commit. ================================================ FILE: docs/server-setup.md ================================================ # Rendezvous Server Setup The `rendezvous` source code is 100% open-source and you can self-host it wherever you want. ## Requirements Rendezvous Server needs two main properties in order to function: 1. Public IP 2. The process can listen to any port 3. Traffic to all ports is allowed in both directions (ingress and egress) ## Fly.io Deployment Breakpoint provides a ready-to-deploy Fly.io configuration. Create a Fly.io application. ```bash $ flyctl apps create rendezvous ``` Allocate a public IPv4 address and assign it to the application. Note that this is a paid feature of Fly.io. ```bash $ flyctl ips allocate-v4 -a rendezvous ``` Take note of the public IPv4 address created before and deploy the `rendezvous` service. ```bash $ flyctl deploy -a rendezvous --env PROXY_PUBLIC={public_ip} ``` Done! Now your instance of Rendezvous Server is listening to `{public_ip}:5000` endpoint. ================================================ FILE: examples/wait.withslack.json ================================================ { "webhooks": [ { "url": "${SLACK_WEBHOOK_URL}", "payload": { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Workflow failed", "emoji": true } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Repository:* (${GITHUB_REF_NAME})" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Workflow:* ${GITHUB_WORKFLOW} ()" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*SSH:* `ssh -p ${BREAKPOINT_PORT} runner@${BREAKPOINT_HOST}`" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Expires:* in ${BREAKPOINT_TIME_LEFT} (${BREAKPOINT_EXPIRATION})" } }, { "type": "context", "elements": [ { "type": "plain_text", "text": "Actor: ${GITHUB_ACTOR}", "emoji": true } ] } ] } } ] } ================================================ FILE: flake.nix ================================================ { inputs = { nixpkgs.url = "github:nixos/nixpkgs"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; in { devShell = pkgs.mkShell { buildInputs = with pkgs; [ go_1_20 buf protobuf protoc-gen-go protoc-gen-go-grpc goreleaser ]; }; }); } ================================================ FILE: fly.toml ================================================ [build] dockerfile = "Dockerfile" [env] PROXY_LISTEN = "fly-global-services:5000" PROXY_PUBLIC = "rendezvous.namespace.so" PROXY_FRONTEND = '{"kind": "proxy_proto", "port_start": 2000, "port_end": 60000, "listen_port": 10000}' PROXY_VALIDATE_GITHUB_OIDC = "true" [[services]] internal_port = 5000 protocol = "udp" auto_stop_machines = false auto_start_machines = false [[services.ports]] port = "5000" [[services]] internal_port = 10000 protocol = "tcp" auto_stop_machines = false auto_start_machines = false [[services.ports]] handlers = ["proxy_proto"] start_port = 2000 end_port = 60000 [[services]] internal_port = 10020 protocol = "tcp" auto_stop_machines = false auto_start_machines = false [[services.ports]] handlers = ["http"] port = 80 force_https = true [[services.ports]] handlers = ["tls", "http"] port = 443 ================================================ FILE: go.mod ================================================ module namespacelabs.dev/breakpoint go 1.20 require ( github.com/MicahParks/keyfunc v1.9.0 github.com/creack/pty v1.1.18 github.com/dustin/go-humanize v1.0.1 github.com/gliderlabs/ssh v0.3.5 github.com/golang-jwt/jwt/v4 v4.4.2 github.com/google/go-cmp v0.5.9 github.com/google/go-github/v52 v52.0.0 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 github.com/muesli/reflow v0.3.0 github.com/pires/go-proxyproto v0.7.0 github.com/pkg/sftp v1.13.5 github.com/quic-go/quic-go v0.40.0 github.com/rs/zerolog v1.29.1 github.com/slack-go/slack v0.12.2 github.com/spf13/cobra v1.7.0 go.uber.org/atomic v1.7.0 golang.org/x/crypto v0.7.0 golang.org/x/exp v0.0.0-20221205204356-47842c84f3db golang.org/x/sync v0.2.0 google.golang.org/grpc v1.55.0 google.golang.org/protobuf v1.30.0 inet.af/tcpproxy v0.0.0-20221017015627-91f861402626 ) require ( github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cloudflare/circl v1.3.3 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.12 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/prometheus/client_golang v1.15.1 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect github.com/quic-go/qtls-go1-20 v0.4.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.uber.org/mock v0.3.0 // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/oauth2 v0.7.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect golang.org/x/tools v0.9.1 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v52 v52.0.0 h1:uyGWOY+jMQ8GVGSX8dkSwCzlehU3WfdxQ7GweO/JP7M= github.com/google/go-github/v52 v52.0.0/go.mod h1:WJV6VEEUPuMo5pXqqa2ZCZEdbQqua4zAk2MZTIo+m+4= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go= github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI= github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs= github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k= github.com/quic-go/quic-go v0.40.0 h1:GYd1iznlKm7dpHD7pOVpUvItgMPo/jrMgDWZhMCecqw= github.com/quic-go/quic-go v0.40.0/go.mod h1:PeN7kuVJ4xZbxSv/4OX6S1USOX8MJvydwpTx31vx60c= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/slack-go/slack v0.12.2 h1:x3OppyMyGIbbiyFhsBmpf9pwkUzMhthJMRNmNlA4LaQ= github.com/slack-go/slack v0.12.2/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o= golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag= google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= inet.af/tcpproxy v0.0.0-20221017015627-91f861402626 h1:2dMP3Ox/Wh5BiItwOt4jxRsfzkgyBrHzx2nW28Yg6nc= inet.af/tcpproxy v0.0.0-20221017015627-91f861402626/go.mod h1:Tojt5kmHpDIR2jMojxzZK2w2ZR7OILODmUo2gaSwjrk= ================================================ FILE: pkg/README.md ================================================ Main components and packages. ================================================ FILE: pkg/bcontrol/client.go ================================================ package bcontrol import ( "context" "net" "os" "path/filepath" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" pb "namespacelabs.dev/breakpoint/api/private/v1" "namespacelabs.dev/breakpoint/pkg/bgrpc" ) func SocketPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { return dir, err } return filepath.Join(dir, "breakpoint/breakpoint.sock"), nil } func Connect(ctx context.Context) (pb.ControlServiceClient, *grpc.ClientConn, error) { socketPath, err := SocketPath() if err != nil { return nil, nil, err } conn, err := bgrpc.DialContext(ctx, socketPath, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", socketPath) })) if err != nil { return nil, nil, err } return pb.NewControlServiceClient(conn), conn, nil } ================================================ FILE: pkg/bgrpc/bgrpc.go ================================================ package bgrpc import ( "context" grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" "google.golang.org/grpc" ) func DialContext(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { unary, streaming := clientInterceptors() opts = append(opts, grpc.WithStreamInterceptor(grpc_middleware.ChainStreamClient(streaming...)), grpc.WithUnaryInterceptor(grpc_middleware.ChainUnaryClient(unary...)), ) return grpc.DialContext(ctx, target, opts...) } func clientInterceptors() ([]grpc.UnaryClientInterceptor, []grpc.StreamClientInterceptor) { return []grpc.UnaryClientInterceptor{ grpc_prometheus.UnaryClientInterceptor, }, []grpc.StreamClientInterceptor{ grpc_prometheus.StreamClientInterceptor, } } ================================================ FILE: pkg/blog/blog.go ================================================ package blog import ( "os" "time" "github.com/rs/zerolog" ) func init() { zerolog.TimeFieldFormat = time.RFC3339Nano } func New() zerolog.Logger { return zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}). With().Timestamp().Logger().Level(zerolog.InfoLevel) } ================================================ FILE: pkg/config/config.go ================================================ package config import ( "context" "errors" "fmt" "os" "runtime" "time" "github.com/rs/zerolog" "google.golang.org/grpc/metadata" internalv1 "namespacelabs.dev/breakpoint/api/private/v1" v1 "namespacelabs.dev/breakpoint/api/public/v1" "namespacelabs.dev/breakpoint/pkg/github" "namespacelabs.dev/breakpoint/pkg/githuboidc" "namespacelabs.dev/breakpoint/pkg/jsonfile" ) func LoadConfig(ctx context.Context, file string) (ParsedConfig, error) { var cfg ParsedConfig if err := jsonfile.Load(file, &cfg.WaitConfig); err != nil { return cfg, err } if cfg.Endpoint == "" { return cfg, errors.New("missing endpoint") } for _, wh := range cfg.Webhooks { if wh.URL == "" { return cfg, errors.New("webhook is missing url") } } if len(cfg.Shell) == 0 { if sh, ok := os.LookupEnv("SHELL"); ok { cfg.Shell = []string{sh} } else { if runtime.GOOS == "windows" { cfg.Shell = []string{"C:\\Windows\\System32\\cmd.exe"} } else { cfg.Shell = []string{"/bin/sh"} } } } requireGitHubOIDC := false for _, feature := range cfg.Enable { switch feature { case "github/oidc": // Force enable. requireGitHubOIDC = false default: return cfg, fmt.Errorf("unknown feature %q", feature) } } cfg.RegisterMetadata = metadata.MD{} if githuboidc.OIDCAvailable() || requireGitHubOIDC { token, err := githuboidc.JWT(ctx, v1.GitHubOIDCAudience) if err != nil { if requireGitHubOIDC { return cfg, err } zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to obtain GitHUB OIDC token") } else { cfg.RegisterMetadata[v1.GitHubOIDCTokenHeader] = []string{token.Value} } } dur, err := time.ParseDuration(cfg.Duration) if err != nil { return cfg, err } cfg.ParsedDuration = dur keyMap, err := github.ResolveSSHKeys(ctx, cfg.AuthorizedGithubUsers) if err != nil { return cfg, err } revIndex := map[string]string{} for _, key := range cfg.AuthorizedKeys { revIndex[key] = key } for user, keys := range keyMap { for _, key := range keys { revIndex[key] = user } } cfg.AllKeys = revIndex return cfg, nil } type ParsedConfig struct { internalv1.WaitConfig AllKeys map[string]string // Key ID -> Owned name ParsedDuration time.Duration RegisterMetadata metadata.MD } ================================================ FILE: pkg/execbackground/bg_unix.go ================================================ //go:build !windows package execbackground import ( "os/exec" "syscall" ) func SetCreateSession(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{ Setsid: true, } } ================================================ FILE: pkg/execbackground/bg_windows.go ================================================ //go:build windows package execbackground import "os/exec" func SetCreateSession(cmd *exec.Cmd) { panic("not supported") } ================================================ FILE: pkg/github/sshkeys.go ================================================ package github import ( "context" "fmt" "io" "net/http" "strings" "time" "github.com/rs/zerolog" ) func ResolveSSHKeys(ctx context.Context, usernames []string) (map[string][]string, error) { // Fetch in sequence to minimize how many requests in parallel we issue to GitHub. m := map[string][]string{} for _, username := range usernames { t := time.Now() keys, err := fetchKeys(username) if err != nil { return nil, fmt.Errorf("failed to fetch SSH keys for GitHub user %q: %w", username, err) } if len(keys) == 0 { zerolog.Ctx(ctx).Warn().Str("username", username).Dur("took", time.Since(t)).Msg("No keys found") continue } m[username] = keys zerolog.Ctx(ctx).Info().Str("username", username).Dur("took", time.Since(t)).Msg("Resolved keys") } return m, nil } func fetchKeys(username string) ([]string, error) { resp, err := http.Get(fmt.Sprintf("https://github.com/%s.keys", username)) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) } contents, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read body: %w", err) } var keys []string for _, line := range strings.FieldsFunc(strings.TrimSpace(string(contents)), func(r rune) bool { return r == '\n' }) { keys = append(keys, strings.TrimSpace(line)) } return keys, nil } ================================================ FILE: pkg/githuboidc/claims.go ================================================ package githuboidc import "github.com/golang-jwt/jwt/v4" type Claims struct { jwt.RegisteredClaims JobWorkflowRef string `json:"job_workflow_ref"` Sha string `json:"sha"` EventName string `json:"event_name"` Repository string `json:"repository"` Workflow string `json:"workflow"` Ref string `json:"ref"` JobWorkflowSha string `json:"job_workflow_sha"` RunnerEnvironment string `json:"runner_environment"` RepositoryID string `json:"repository_id"` RepositoryOwner string `json:"repository_owner"` RepositoryOwnerID string `json:"repository_owner_id"` WorkflowRef string `json:"workflow_ref"` WorkflowSha string `json:"workflow_sha"` RunID string `json:"run_id"` RunAttempt string `json:"run_attempt"` } ================================================ FILE: pkg/githuboidc/gh.go ================================================ package githuboidc import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "namespacelabs.dev/breakpoint/pkg/httperrors" ) var ErrMissingIdTokenWrite = errors.New("please add `id-token: write` to your workflow permissions") const ( userAgent = "actions/oidc-client" ) type Token struct { Value string `json:"value"` } func OIDCAvailable() bool { x, y := oidcConf() return x != "" && y != "" } func JWT(ctx context.Context, audience string) (*Token, error) { idTokenURL, idToken := oidcConf() if idTokenURL == "" || idToken == "" { return nil, ErrMissingIdTokenWrite } if audience != "" { idTokenURL += fmt.Sprintf("&audience=%s", url.QueryEscape(audience)) } req, err := http.NewRequestWithContext(ctx, "GET", idTokenURL, nil) if err != nil { return nil, fmt.Errorf("github/oidc: failed to create HTTP request: %w", err) } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("User-Agent", userAgent) req.Header.Add("Authorization", "Bearer "+idToken) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("github/oidc: failed to request github JWT: %w", err) } defer resp.Body.Close() if err := httperrors.MaybeError(resp); err != nil { return nil, fmt.Errorf("github/oidc: failed to obtain token: %v", err) } var token Token if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { return nil, fmt.Errorf("github/oidc: bad response: %w", err) } return &token, nil } func oidcConf() (string, string) { idTokenURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") idToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") return idTokenURL, idToken } ================================================ FILE: pkg/githuboidc/verifier.go ================================================ package githuboidc import ( "context" "errors" "fmt" "time" "github.com/MicahParks/keyfunc" "github.com/golang-jwt/jwt/v4" "github.com/rs/zerolog" ) const ( githubJWKSURL = "https://token.actions.githubusercontent.com/.well-known/jwks" ) func ProvideVerifier(ctx context.Context) (*keyfunc.JWKS, error) { options := keyfunc.Options{ Ctx: ctx, RefreshErrorHandler: func(err error) { zerolog.Ctx(ctx).Err(err).Str("jwks_url", githubJWKSURL).Msg("Failed to refresh JWKS") }, RefreshInterval: time.Hour, RefreshRateLimit: time.Minute * 5, RefreshTimeout: time.Second * 10, RefreshUnknownKID: true, } return keyfunc.Get(githubJWKSURL, options) } func Validate(ctx context.Context, jwks *keyfunc.JWKS, tokenStr string) (*Claims, error) { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenStr, claims, jwks.Keyfunc) if err != nil { return nil, fmt.Errorf("failed to verify Github JWT: %w", err) } if !token.Valid { return nil, errors.New("invalid Github JWT") } return claims, nil } ================================================ FILE: pkg/httperrors/httperrors.go ================================================ package httperrors import ( "fmt" "io" "net/http" ) type HttpError struct { StatusCode int ServerError string } func (he HttpError) Error() string { if len(he.ServerError) > 0 { return fmt.Sprintf("request failed with %s, got from the server:\n%s", http.StatusText(he.StatusCode), he.ServerError) } return fmt.Sprintf("request failed with %s", http.StatusText(he.StatusCode)) } func MaybeError(resp *http.Response) error { if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return &HttpError{StatusCode: resp.StatusCode, ServerError: string(bodyBytes)} } return nil } ================================================ FILE: pkg/internalserver/internalserver.go ================================================ package internalserver import ( "context" "log" "net" "os" "path/filepath" "golang.org/x/sync/errgroup" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" pb "namespacelabs.dev/breakpoint/api/private/v1" "namespacelabs.dev/breakpoint/pkg/bcontrol" "namespacelabs.dev/breakpoint/pkg/waiter" ) type waiterService struct { manager *waiter.Manager pb.UnimplementedControlServiceServer } func ListenAndServe(ctx context.Context, mgr *waiter.Manager) error { socketPath, err := bcontrol.SocketPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil { return err } _ = os.Remove(socketPath) // Remove any leftovers. defer func() { _ = os.Remove(socketPath) }() var d net.ListenConfig lis, err := d.Listen(ctx, "unix", socketPath) if err != nil { log.Fatalf("failed to listen: %v", err) } grpcServer := grpc.NewServer() pb.RegisterControlServiceServer(grpcServer, waiterService{ manager: mgr, }) eg, ctx := errgroup.WithContext(ctx) eg.Go(func() error { <-ctx.Done() grpcServer.Stop() return nil }) eg.Go(func() error { return grpcServer.Serve(lis) }) return eg.Wait() } func (g waiterService) Extend(ctx context.Context, req *pb.ExtendRequest) (*pb.ExtendResponse, error) { expiration := g.manager.ExtendWait(req.WaitFor.AsDuration()) return &pb.ExtendResponse{ Expiration: timestamppb.New(expiration), }, nil } func (g waiterService) Status(ctx context.Context, req *emptypb.Empty) (*pb.StatusResponse, error) { status := g.manager.Status() return &pb.StatusResponse{ Expiration: timestamppb.New(status.Expiration), Endpoint: status.Endpoint, NumConnections: status.NumConnections, }, nil } func (g waiterService) Resume(ctx context.Context, req *emptypb.Empty) (*emptypb.Empty, error) { g.manager.StopWait() return &emptypb.Empty{}, nil } ================================================ FILE: pkg/jsonfile/load.go ================================================ package jsonfile import ( "encoding/json" "os" ) func Load(filename string, target any) error { f, err := os.Open(filename) if err != nil { return err } return json.NewDecoder(f).Decode(target) } ================================================ FILE: pkg/passthrough/listener.go ================================================ package passthrough import ( "context" "errors" "net" "go.uber.org/atomic" ) type Listener struct { ctx context.Context addr net.Addr ch chan net.Conn closed *atomic.Bool } func NewListener(ctx context.Context, addr net.Addr) Listener { return Listener{ctx: ctx, addr: addr, ch: make(chan net.Conn), closed: atomic.NewBool(false)} } func (pl Listener) Accept() (net.Conn, error) { select { case <-pl.ctx.Done(): return nil, pl.ctx.Err() case conn, ok := <-pl.ch: if !ok { return nil, errors.New("listener is closed") } return conn, nil } } func (pl Listener) Addr() net.Addr { return pl.addr } func (pl Listener) Close() error { if !pl.closed.Swap(true) { close(pl.ch) return nil } else { return errors.New("already closed") } } func (pl Listener) Offer(conn net.Conn) error { if pl.closed.Load() { return errors.New("listener closed") } pl.ch <- conn return nil } ================================================ FILE: pkg/quicgrpc/grpccreds.go ================================================ package quicgrpc import ( "context" "net" "github.com/quic-go/quic-go" "google.golang.org/grpc/credentials" "namespacelabs.dev/breakpoint/pkg/quicnet" ) type QuicCreds struct { NonQuicCreds credentials.TransportCredentials } var _ credentials.TransportCredentials = QuicCreds{} func (m QuicCreds) ClientHandshake(ctx context.Context, addr string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { return m.NonQuicCreds.ClientHandshake(ctx, addr, conn) } func (m QuicCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { if quic, ok := conn.(quicnet.Conn); ok { return conn, QuicAuthInfo{Conn: quic.Conn}, nil } return m.NonQuicCreds.ServerHandshake(conn) } func (m QuicCreds) Info() credentials.ProtocolInfo { return credentials.ProtocolInfo{SecurityProtocol: "insecure"} } func (m QuicCreds) Clone() credentials.TransportCredentials { return QuicCreds{NonQuicCreds: m.NonQuicCreds.Clone()} } func (m QuicCreds) OverrideServerName(string) error { return nil } type QuicAuthInfo struct { credentials.CommonAuthInfo Conn quic.Connection } func (QuicAuthInfo) AuthType() string { return "quic" } ================================================ FILE: pkg/quicnet/conn.go ================================================ package quicnet import ( "context" "net" "github.com/quic-go/quic-go" ) type Conn struct { quic.Stream Conn quic.Connection } func (cw Conn) LocalAddr() net.Addr { return cw.Conn.LocalAddr() } func (cw Conn) RemoteAddr() net.Addr { return cw.Conn.RemoteAddr() } func OpenStream(ctx context.Context, conn quic.Connection) (Conn, error) { stream, err := conn.OpenStreamSync(ctx) if err != nil { return Conn{}, err } return Conn{Stream: stream, Conn: conn}, nil } ================================================ FILE: pkg/quicnet/listener.go ================================================ package quicnet import ( "context" "errors" "net" "sync" "time" "github.com/quic-go/quic-go" "github.com/rs/zerolog" ) var ( errClosed = errors.New("closed") errAlreadyClosed = errors.New("already closed") ) type Listener struct { ctx context.Context listener quic.Listener mu sync.Mutex cond *sync.Cond inbox []net.Conn lErr error // If set, the listener is closed. } func NewListener(ctx context.Context, l quic.Listener) *Listener { lst := &Listener{ctx: ctx, listener: l} lst.cond = sync.NewCond(&lst.mu) go lst.loop() return lst } func (l *Listener) loop() { for { conn, err := l.listener.Accept(l.ctx) if err != nil { _ = l.closeWithErr(err) return } go l.waitForStream(conn) } } func (l *Listener) closeWithErr(err error) error { l.mu.Lock() wasErr := l.lErr inbox := l.inbox if l.lErr == nil { l.lErr = err l.inbox = nil } l.cond.Broadcast() l.mu.Unlock() if wasErr != nil { return errAlreadyClosed } for _, conn := range inbox { _ = conn.Close() } _ = l.listener.Close() return nil } func (l *Listener) waitForStream(conn quic.Connection) { // If we don't see a stream within the deadline, then close the connection. ctx, done := context.WithTimeout(l.ctx, 10*time.Second) defer done() stream, err := conn.AcceptStream(ctx) if err != nil { zerolog.Ctx(ctx).Info().Stringer("remote_addr", conn.RemoteAddr()). Stringer("local_addr", conn.LocalAddr()).Err(err).Msg("Failed to accept stream") conn.CloseWithError(0, "") return } l.queue(conn, stream) } func (l *Listener) queue(conn quic.Connection, stream quic.Stream) { l.mu.Lock() lErr := l.lErr if l.lErr == nil { l.inbox = append(l.inbox, Conn{Conn: conn, Stream: stream}) l.cond.Signal() } l.mu.Unlock() if lErr != nil { zerolog.Ctx(l.ctx).Info().Stringer("remote_addr", conn.RemoteAddr()). Stringer("local_addr", conn.LocalAddr()).Err(lErr).Msg("Listener was closed") conn.CloseWithError(0, "") } } func (l *Listener) Accept() (net.Conn, error) { l.mu.Lock() defer l.mu.Unlock() for len(l.inbox) == 0 { l.cond.Wait() if l.lErr != nil { return nil, l.lErr } if err := l.ctx.Err(); err != nil { return nil, err } } conn := l.inbox[0] l.inbox = l.inbox[1:] return conn, nil } func (l *Listener) Close() error { return l.closeWithErr(errClosed) } func (l *Listener) Addr() net.Addr { return l.listener.Addr() } ================================================ FILE: pkg/quicproxy/proxyproto.go ================================================ package quicproxy import ( "context" "errors" "fmt" "math/rand" "net" "sync" proxyproto "github.com/pires/go-proxyproto" "github.com/rs/zerolog" ) type ProxyProtoFrontend struct { ListenPort int PortStart, PortEnd int PublicAddr string mu sync.RWMutex alloc map[int]func(net.Conn) } func (pf *ProxyProtoFrontend) ListenAndServe(ctx context.Context) error { var l net.ListenConfig lst, err := l.Listen(ctx, "tcp", fmt.Sprintf(":%d", pf.ListenPort)) if err != nil { return err } go func() { <-ctx.Done() _ = lst.Close() }() proxyListener := &proxyproto.Listener{Listener: lst} for { conn, err := proxyListener.Accept() if err != nil { return err } l := zerolog.Ctx(ctx).With().Stringer("remote_addr", conn.RemoteAddr()). Stringer("local_addr", conn.LocalAddr()).Logger() if tcpaddr, ok := conn.LocalAddr().(*net.TCPAddr); ok { go func() { pf.mu.RLock() handler, ok := pf.alloc[tcpaddr.Port] if ok { l.Debug().Msg("New connection") // Call handler with the rlock held to make sure we're // always handling streams consistently. Handler will // quickly spawn a go routine and return. handler(conn) } else { l.Debug().Msg("No match") } pf.mu.RUnlock() // Close without holding the lock. if !ok { _ = conn.Close() } }() } else { l.Debug().Msg("Ignored non-tcp") _ = conn.Close() } } } func (pf *ProxyProtoFrontend) allocate(ctx context.Context, handler func(net.Conn)) (int, func(), error) { pf.mu.Lock() defer pf.mu.Unlock() // XXX naive; move to pre-shuffle. for i := 0; i < 100; i++ { port := pf.PortStart + rand.Int()%(pf.PortEnd-pf.PortStart) if _, ok := pf.alloc[port]; !ok { if pf.alloc == nil { pf.alloc = map[int]func(net.Conn){} } pf.alloc[port] = handler return port, func() { pf.mu.Lock() delete(pf.alloc, port) pf.mu.Unlock() }, nil } } return -1, nil, errors.New("failed to allocate port") } func (pf *ProxyProtoFrontend) Handle(ctx context.Context, handlers Handlers) error { port, cleanup, err := pf.allocate(ctx, func(conn net.Conn) { go handlers.HandleConn(conn) }) if err != nil { return err } defer cleanup() alloc := Allocation{Endpoint: fmt.Sprintf("%s:%d", pf.PublicAddr, port)} if err := handlers.OnAllocation(alloc); err != nil { return err } <-ctx.Done() ctxErr := ctx.Err() if handlers.OnCleanup != nil { handlers.OnCleanup(alloc, ctxErr) } return ctxErr } ================================================ FILE: pkg/quicproxy/rawproto.go ================================================ package quicproxy import ( "context" "fmt" "net" "github.com/rs/zerolog" ) type RawFrontend struct { PublicAddr string } func (rf RawFrontend) ListenAndServe(ctx context.Context) error { return nil } func (rf RawFrontend) Handle(ctx context.Context, handlers Handlers) error { var d net.ListenConfig listener, err := d.Listen(ctx, "tcp", "0.0.0.0:0") if err != nil { return err } // If the context is canceled (e.g. the registration stream breaks), also // stop the listener. go func() { <-ctx.Done() _ = listener.Close() }() // If we leave the Serve handler for reasons other than the listener // closing, make sure it's closed. defer func() { _ = listener.Close() }() port := listener.Addr().(*net.TCPAddr).Port alloc := Allocation{Endpoint: fmt.Sprintf("%s:%d", rf.PublicAddr, port)} if err := handlers.OnAllocation(alloc); err != nil { return err } for { conn, err := listener.Accept() if err != nil { if handlers.OnCleanup != nil { handlers.OnCleanup(alloc, err) } return err } zerolog.Ctx(ctx).Debug().Stringer("remote_addr", conn.RemoteAddr()). Stringer("local_addr", conn.LocalAddr()). Str("allocation", alloc.Endpoint).Msg("New connection") go handlers.HandleConn(conn) } } ================================================ FILE: pkg/quicproxy/serve.go ================================================ package quicproxy import ( "context" "errors" "net" "time" "github.com/quic-go/quic-go" "github.com/rs/zerolog" "inet.af/tcpproxy" "namespacelabs.dev/breakpoint/pkg/quicnet" ) type Allocation struct { Endpoint string } type ProxyFrontend interface { ListenAndServe(context.Context) error Handle(context.Context, Handlers) error } type Handlers struct { OnAllocation func(Allocation) error OnCleanup func(Allocation, error) HandleConn func(net.Conn) } func ServeProxy(ctx context.Context, frontend ProxyFrontend, conn quic.Connection, callback func(Allocation) error) error { backend := tcpproxy.To("backend") backend.DialTimeout = 30 * time.Second backend.ProxyProtocolVersion = 1 backend.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { return quicnet.OpenStream(ctx, conn) } return frontend.Handle(ctx, Handlers{ OnAllocation: func(alloc Allocation) error { zerolog.Ctx(ctx).Info().Str("allocation", alloc.Endpoint).Msg("New allocation") return callback(alloc) }, OnCleanup: func(alloc Allocation, err error) { zerolog.Ctx(ctx).Info().Str("allocation", alloc.Endpoint).Err(cancelIsOK(err)).Msg("Released allocation") }, HandleConn: backend.HandleConn, }) } func cancelIsOK(err error) error { if errors.Is(err, context.Canceled) { return nil } return err } ================================================ FILE: pkg/quicproxy/service.go ================================================ package quicproxy import ( "context" "crypto/tls" "errors" "time" "github.com/MicahParks/keyfunc" "github.com/quic-go/quic-go" "github.com/rs/zerolog" "golang.org/x/exp/slices" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" apipb "namespacelabs.dev/breakpoint/api/public/v1" "namespacelabs.dev/breakpoint/pkg/githuboidc" "namespacelabs.dev/breakpoint/pkg/quicgrpc" "namespacelabs.dev/breakpoint/pkg/quicnet" "namespacelabs.dev/breakpoint/pkg/quicproxyclient" "namespacelabs.dev/breakpoint/pkg/tlscerts" ) type Server struct { p ProxyFrontend listener quic.Listener ghJWKS *keyfunc.JWKS } type ServerOpts struct { ProxyFrontend ProxyFrontend ListenAddr string Subjects tlscerts.Subjects EnableGitHubOIDC bool } func NewServer(ctx context.Context, opts ServerOpts) (*Server, error) { t := time.Now() public, private, err := tlscerts.GenerateECDSAPair(opts.Subjects, 365*24*time.Hour) if err != nil { return nil, err } zerolog.Ctx(ctx).Info().Dur("took", time.Since(t)).Msg("Generated new keys") srv := &Server{p: opts.ProxyFrontend} if opts.EnableGitHubOIDC { t = time.Now() jwks, err := githuboidc.ProvideVerifier(ctx) if err != nil { return nil, err } zerolog.Ctx(ctx).Info().Dur("took", time.Since(t)).Msg("Prepared GitHub JWKS") srv.ghJWKS = jwks } cert, err := tls.X509KeyPair(public, private) if err != nil { return nil, err } tlsconf := &tls.Config{ Certificates: []tls.Certificate{cert}, NextProtos: []string{apipb.QuicProto}, } listener, err := quic.ListenAddr(opts.ListenAddr, tlsconf, quicproxyclient.DefaultConfig) if err != nil { return nil, err } srv.listener = *listener return srv, nil } func (srv *Server) Close() error { return srv.listener.Close() } func (srv *Server) Serve(ctx context.Context) error { zerolog.Ctx(ctx).Info().Str("addr", srv.listener.Addr().String()).Msg("Listening") grpcServer := grpc.NewServer(grpc.Creds(quicgrpc.QuicCreds{NonQuicCreds: insecure.NewCredentials()})) apipb.RegisterProxyServiceServer(grpcServer, server{ logger: zerolog.Ctx(ctx).With().Logger(), frontend: srv.p, ghJWKS: srv.ghJWKS, }) return grpcServer.Serve(quicnet.NewListener(ctx, srv.listener)) } type server struct { apipb.UnimplementedProxyServiceServer logger zerolog.Logger frontend ProxyFrontend ghJWKS *keyfunc.JWKS restrictToRepositories []string restrictToOwners []string } func (srv server) Register(req *apipb.RegisterRequest, server apipb.ProxyService_RegisterServer) error { peer, _ := peer.FromContext(server.Context()) quic, ok := peer.AuthInfo.(quicgrpc.QuicAuthInfo) if !ok { return errors.New("internal error, expected quic") } githubClaims, logger := validateGitHubOIDC(server.Context(), srv.logger, srv.ghJWKS) if len(srv.restrictToRepositories) > 0 { if githubClaims == nil || !slices.Contains(srv.restrictToRepositories, githubClaims.Repository) { return status.Errorf(codes.PermissionDenied, "repository %q not allowed", githubClaims.Repository) } } if len(srv.restrictToOwners) > 0 { if githubClaims == nil || !slices.Contains(srv.restrictToOwners, githubClaims.RepositoryOwner) { return status.Errorf(codes.PermissionDenied, "repository owner %q not allowed", githubClaims.RepositoryOwner) } } return ServeProxy(logger.WithContext(server.Context()), srv.frontend, quic.Conn, func(alloc Allocation) error { return server.Send(&apipb.RegisterResponse{Endpoint: alloc.Endpoint}) }) } func validateGitHubOIDC(ctx context.Context, logger zerolog.Logger, jwks *keyfunc.JWKS) (*githuboidc.Claims, zerolog.Logger) { if jwks != nil { md, _ := metadata.FromIncomingContext(ctx) if token, ok := md[apipb.GitHubOIDCTokenHeader]; ok && len(token) > 0 { claims, err := githuboidc.Validate(ctx, jwks, token[0]) if err != nil { logger.Warn().Err(err).Msg("Failed to validate GitHub OIDC Token") } else if slices.Contains(claims.Audience, apipb.GitHubOIDCAudience) { logger.Warn().Str("expected", apipb.GitHubOIDCAudience).Strs("audience", claims.Audience). Msg("Failed to validate GitHub OIDC Token audience") } else { return claims, logger.With().Str("repository", claims.Repository).Logger() } } } return nil, logger } ================================================ FILE: pkg/quicproxyclient/client.go ================================================ package quicproxyclient import ( "context" "crypto/tls" "errors" "net" "time" proxyproto "github.com/pires/go-proxyproto" "github.com/quic-go/quic-go" "github.com/rs/zerolog" "golang.org/x/sync/errgroup" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" v1 "namespacelabs.dev/breakpoint/api/public/v1" "namespacelabs.dev/breakpoint/pkg/bgrpc" "namespacelabs.dev/breakpoint/pkg/quicnet" ) var DefaultConfig = &quic.Config{ MaxIdleTimeout: 5 * time.Second, KeepAlivePeriod: 30 * time.Second, } type Handlers struct { OnAllocation func(string) Proxy func(net.Conn) error } func Serve(ctx context.Context, endpoint string, md metadata.MD, handlers Handlers) error { tlsConf := &tls.Config{ InsecureSkipVerify: true, NextProtos: []string{v1.QuicProto}, } zerolog.Ctx(ctx).Info().Str("endpoint", endpoint).Msg("Connecting") conn, err := quic.DialAddr(ctx, endpoint, tlsConf, DefaultConfig) if err != nil { return err } grpconn, err := bgrpc.DialContext(ctx, endpoint, grpc.WithBlock(), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return quicnet.OpenStream(ctx, conn) }), ) if err != nil { return err } cli := v1.NewProxyServiceClient(grpconn) rsrv, err := cli.Register(metadata.NewOutgoingContext(ctx, md), &v1.RegisterRequest{}) if err != nil { return err } eg, ctx := errgroup.WithContext(ctx) eg.Go(func() error { for { stream, err := conn.AcceptStream(ctx) if err != nil { if !errors.Is(err, context.Canceled) { zerolog.Ctx(ctx).Err(err).Msg("accept failed") } return err } pconn := proxyproto.NewConn(quicnet.Conn{Stream: stream, Conn: conn}) zerolog.Ctx(ctx).Info().Stringer("remote_addr", pconn.RemoteAddr()). Stringer("local_addr", pconn.LocalAddr()).Msg("New remote connection") if err := handlers.Proxy(pconn); err != nil { zerolog.Ctx(ctx).Err(err).Msg("handle failed") return err } } }) eg.Go(func() error { for { msg, err := rsrv.Recv() if err != nil { return err } handlers.OnAllocation(msg.Endpoint) } }) return eg.Wait() } ================================================ FILE: pkg/sshd/keepalive.go ================================================ package sshd import ( "context" "errors" "io" "time" "github.com/gliderlabs/ssh" "github.com/rs/zerolog" ) func keepAlive(ctx context.Context, logger zerolog.Logger, session ssh.Session) { t := time.NewTicker(15 * time.Second) defer t.Stop() for { select { case <-t.C: t := time.Now() if _, err := session.SendRequest("keepalive@openssh.com", true, nil); err != nil { if !errors.Is(err, io.EOF) { logger.Err(err).Msg("Failed to send keepalive") } else { return } } else { logger.Debug().Dur("took", time.Since(t)).Msg("Got KeepAlive response") } case <-ctx.Done(): return } } } ================================================ FILE: pkg/sshd/pty_unix.go ================================================ //go:build !windows package sshd import ( "fmt" "io" "os" "os/exec" "syscall" "unsafe" "github.com/creack/pty" "github.com/gliderlabs/ssh" ) func handlePty(session io.ReadWriter, ptyReq ssh.Pty, winCh <-chan ssh.Window, cmd *exec.Cmd) error { cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) ptyFile, err := pty.Start(cmd) if err != nil { return err } defer ptyFile.Close() go syncWinSize(ptyFile, winCh) go func() { _, _ = io.Copy(ptyFile, session) // stdin }() _, _ = io.Copy(session, ptyFile) // stdout return nil } func syncWinSize(ptyFile *os.File, winCh <-chan ssh.Window) { for win := range winCh { setWinsize(ptyFile, win.Width, win.Height) } } func setWinsize(f *os.File, w, h int) { syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0}))) } ================================================ FILE: pkg/sshd/pty_windows.go ================================================ //go:build windows package sshd import ( "errors" "io" "os/exec" "github.com/gliderlabs/ssh" ) func handlePty(session io.ReadWriter, ptyReq ssh.Pty, winCh <-chan ssh.Window, cmd *exec.Cmd) error { return errors.New("pty not supported in windows") } ================================================ FILE: pkg/sshd/sftp.go ================================================ package sshd import ( "io" "github.com/gliderlabs/ssh" "github.com/pkg/sftp" "github.com/rs/zerolog" ) func makeSftpHandler(logger zerolog.Logger) ssh.SubsystemHandler { return func(sess ssh.Session) { server, err := sftp.NewServer(sess, sftp.WithDebug(io.Discard)) if err != nil { logger.Err(err).Msg("sftp: failed to init server") return } defer server.Close() if err := server.Serve(); err != nil && err != io.EOF { logger.Err(err).Msg("sftp: session done with error") } else { logger.Info().Msg("sftp: session done") } } } ================================================ FILE: pkg/sshd/sshd.go ================================================ package sshd import ( "context" "crypto/rand" "crypto/rsa" "fmt" "io" "net" "os/exec" "runtime" "time" "github.com/gliderlabs/ssh" "github.com/rs/zerolog" "go.uber.org/atomic" gossh "golang.org/x/crypto/ssh" "golang.org/x/exp/maps" "golang.org/x/exp/slices" ) type SSHServerOpts struct { AllowedUsers []string AuthorizedKeys map[string]string // Key to owner Env []string Shell []string Dir string InteractiveMOTD func(io.Writer) } type sshKey struct { Key ssh.PublicKey Owner string } type SSHServer struct { Server *ssh.Server NumConnections func() uint32 } func MakeServer(ctx context.Context, opts SSHServerOpts) (*SSHServer, error) { var authorizedKeys []sshKey for key, owner := range opts.AuthorizedKeys { key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) if err != nil { return nil, err } authorizedKeys = append(authorizedKeys, sshKey{key, owner}) } l := zerolog.Ctx(ctx).With().Str("service", "sshd").Logger() connCount := atomic.NewUint32(0) srv := &ssh.Server{ Handler: func(session ssh.Session) { key, _ := lookupKey(authorizedKeys, session.PublicKey()) sessionLog := l.With().Stringer("remote_addr", session.RemoteAddr()).Str("owner", key.Owner).Logger() sessionLog.Info().Str("user", session.User()).Msg("incoming ssh session") args := opts.Shell[1:] if session.RawCommand() != "" { if runtime.GOOS == "windows" { args = []string{"/C", session.RawCommand()} } else { args = []string{"-c", session.RawCommand()} } } cmd := exec.Command(opts.Shell[0], args...) cmd.Env = slices.Clone(opts.Env) cmd.Dir = opts.Dir if ssh.AgentRequested(session) { l, err := ssh.NewAgentListener() if err != nil { fmt.Fprintf(session, "Failed to forward agent.\n") } else { defer l.Close() go ssh.ForwardAgentConnections(l, session) cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", "SSH_AUTH_SOCK", l.Addr().String())) } } ptyReq, winCh, isPty := session.Pty() sessionLog.Info().Bool("ssh_agent", ssh.AgentRequested(session)).Bool("pty", isPty).Msg("ssh session") ctx, cancel := context.WithCancel(session.Context()) defer cancel() // Make sure that the connection with the client is kept alive. go keepAlive(ctx, sessionLog, session) if isPty { // Print MOTD only if no command was provided if opts.InteractiveMOTD != nil && session.RawCommand() == "" { opts.InteractiveMOTD(session) } if err := handlePty(session, ptyReq, winCh, cmd); err != nil { sessionLog.Err(err).Msg("pty start failed") session.Exit(1) return } } else { cmd.Stdout = session cmd.Stderr = session if err := cmd.Start(); err != nil { sessionLog.Err(err).Msg("start failed") session.Exit(1) return } } // XXX pass exit code to caller? err := cmd.Wait() sessionLog.Info().Err(err).Msg("ssh session end") }, SessionRequestCallback: func(sess ssh.Session, requestType string) bool { return len(opts.AllowedUsers) == 0 || slices.Contains(opts.AllowedUsers, sess.User()) }, PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { _, allowed := lookupKey(authorizedKeys, key) return allowed }, LocalPortForwardingCallback: func(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { sessionLog := l.With().Stringer("remote_addr", ctx.RemoteAddr()).Logger() sessionLog.Info().Str("dst", fmt.Sprintf("%s:%d", destinationHost, destinationPort)).Msg("Port forward request") return true }, SubsystemHandlers: map[string]ssh.SubsystemHandler{ "sftp": makeSftpHandler(l), }, ConnCallback: func(ctx ssh.Context, conn net.Conn) net.Conn { connCount.Inc() go func() { <-ctx.Done() connCount.Dec() }() return conn }, } srv.ChannelHandlers = maps.Clone(ssh.DefaultChannelHandlers) srv.ChannelHandlers["direct-tcpip"] = ssh.DirectTCPIPHandler t := time.Now() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, err } signer, err := gossh.NewSignerFromKey(key) if err != nil { return nil, err } srv.HostSigners = append(srv.HostSigners, signer) zerolog.Ctx(ctx).Info().Str("host_key_fingerprint", gossh.FingerprintSHA256(signer.PublicKey())).Dur("took", time.Since(t)).Msg("Generated ssh host key") return &SSHServer{ Server: srv, NumConnections: connCount.Load, }, nil } func lookupKey(allowed []sshKey, key ssh.PublicKey) (sshKey, bool) { for _, allowed := range allowed { if ssh.KeysEqual(key, allowed.Key) { return allowed, true } } return sshKey{}, false } ================================================ FILE: pkg/tlscerts/tlscerts.go ================================================ package tlscerts import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/pem" "math/big" "net" "time" ) type Subjects struct { DNSNames []string IPAddresses []net.IP } func GenerateECDSAPair(subjects Subjects, duration time.Duration) ([]byte, []byte, error) { serial, err := newSerialNumber() if err != nil { return nil, nil, err } priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, nil, err } privDer, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return nil, nil, err } privPem := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDer}) template := &x509.Certificate{ SerialNumber: serial, NotAfter: time.Now().Add(duration), DNSNames: subjects.DNSNames, IPAddresses: subjects.IPAddresses, } certDer, err := x509.CreateCertificate(rand.Reader, template, template, priv.Public(), priv) if err != nil { return nil, nil, err } certPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDer}) if err != nil { return nil, nil, err } return certPem, privPem, nil } func newSerialNumber() (*big.Int, error) { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) return rand.Int(rand.Reader, serialNumberLimit) } ================================================ FILE: pkg/waiter/output.go ================================================ package waiter import ( "fmt" "io" "net" "strings" "time" "github.com/dustin/go-humanize" "github.com/muesli/reflow/wordwrap" ) func PrintConnectionInfo(endpoint string, deadline time.Time, output io.Writer) { host, port, _ := net.SplitHostPort(endpoint) if host == "" && port == "" { return } ww := wordwrap.NewWriter(80) fmt.Fprintf(ww, "Breakpoint! Running until %v (%v).", deadline.Format(Stamp), humanize.Time(deadline)) _ = ww.Close() lines := strings.Split(ww.String(), "\n") longestLine := 0 for _, l := range lines { if len(l) > longestLine { longestLine = len(l) } } longline := nchars('─', longestLine) spaces := nchars(' ', longestLine) fmt.Fprintln(output) fmt.Fprintf(output, "┌─%s─┐\n", longline) for _, l := range lines { fmt.Fprintf(output, "│ %s%s │\n", l, spaces[len(l):]) } fmt.Fprintf(output, "└─%s─┘\n", longline) fmt.Fprintln(output) fmt.Fprintf(output, "Connect with:\n\n") fmt.Fprintf(output, "ssh -p %s runner@%s\n", port, host) } ================================================ FILE: pkg/waiter/slackbot.go ================================================ package waiter import ( "context" "fmt" "net" "os" "time" "github.com/dustin/go-humanize" "github.com/google/go-github/v52/github" "github.com/rs/zerolog" "github.com/slack-go/slack" v1 "namespacelabs.dev/breakpoint/api/private/v1" "namespacelabs.dev/breakpoint/pkg/jsonfile" ) type botInstance struct { client *slack.Client m *Manager githubProps renderGitHubProps channelID string ts string } func startBot(ctx context.Context, m *Manager, conf v1.SlackBot) *botInstance { bot := &botInstance{ client: slack.New(os.ExpandEnv(conf.Token)), m: m, githubProps: prepareGitHubProps(ctx), } chid, ts, err := bot.client.PostMessageContext(ctx, os.ExpandEnv(conf.Channel), bot.makeBlocks(false)) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("SlackBot failed") return nil } bot.channelID = chid bot.ts = ts go bot.loop(ctx) return bot } func (b *botInstance) Close() error { ctx, done := context.WithTimeout(context.Background(), 5*time.Second) defer done() return b.sendUpdate(ctx, true) } func (b *botInstance) makeBlocks(leaving bool) slack.MsgOption { if leaving { return slack.MsgOptionBlocks(renderGitHubMessage(b.githubProps, "", time.Time{})...) } return slack.MsgOptionBlocks(renderGitHubMessage(b.githubProps, b.m.Endpoint(), b.m.Expiration())...) } func (b *botInstance) sendUpdate(ctx context.Context, leaving bool) error { _, _, _, err := b.client.UpdateMessageContext(ctx, b.channelID, b.ts, b.makeBlocks(leaving)) return err } func (b *botInstance) loop(ctx context.Context) error { t := time.NewTicker(30 * time.Second) defer t.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-t.C: if err := b.sendUpdate(ctx, false); err != nil { return err } } } } type renderGitHubProps struct { Repository string RefName string Workflow string RunID string RunNumber string Actor string PushEvent *github.PushEvent // Only set on push events. } func prepareGitHubProps(ctx context.Context) renderGitHubProps { props := renderGitHubProps{ Repository: os.Getenv("GITHUB_REPOSITORY"), RefName: os.Getenv("GITHUB_REF_NAME"), Workflow: os.Getenv("GITHUB_WORKFLOW"), RunID: os.Getenv("GITHUB_RUN_ID"), RunNumber: os.Getenv("GITHUB_RUN_NUMBER"), Actor: os.Getenv("GITHUB_ACTOR"), } if eventFile := os.Getenv("GITHUB_EVENT_PAH"); os.Getenv("GITHUB_EVENT_NAME") == "push" && eventFile != "" { var pushEvent github.PushEvent if err := jsonfile.Load(eventFile, &pushEvent); err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to load event file") } else { props.PushEvent = &pushEvent } } return props } func renderGitHubMessage(props renderGitHubProps, endpoint string, exp time.Time) []slack.Block { blocks := []slack.Block{ slack.NewHeaderBlock(slack.NewTextBlockObject(slack.PlainTextType, "Workflow failed", false, false)), slack.NewSectionBlock(slack.NewTextBlockObject( slack.MarkdownType, fmt.Sprintf("*Repository:* (%s)", props.Repository, props.RefName, props.Repository, props.RefName), false, false, ), nil, nil), slack.NewSectionBlock(slack.NewTextBlockObject( slack.MarkdownType, fmt.Sprintf("*Workflow:* %s ()", props.Workflow, props.Repository, props.RunID, props.RunNumber), false, false, ), nil, nil), } if props.PushEvent != nil && props.PushEvent.HeadCommit != nil && props.PushEvent.HeadCommit.Message != nil { blocks = append(blocks, slack.NewSectionBlock(slack.NewTextBlockObject( slack.MarkdownType, fmt.Sprintf("*<%s|Commit>:* %s`", maybeCommitURL(props.Repository, *props.PushEvent), *props.PushEvent.HeadCommit.Message), false, false, ), nil, nil)) } if endpoint != "" && !exp.IsZero() { host, port, _ := net.SplitHostPort(endpoint) blocks = append(blocks, slack.NewSectionBlock(slack.NewTextBlockObject( slack.MarkdownType, fmt.Sprintf("*SSH:* `ssh -p %s runner@%s`", port, host), false, false, ), nil, nil), slack.NewSectionBlock(slack.NewTextBlockObject( slack.MarkdownType, fmt.Sprintf("*Expires:* %s (%s)", humanize.Time(exp), exp.Format(Stamp)), false, false, ), nil, nil), ) } blocks = append(blocks, slack.NewContextBlock("", slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("Actor: %s", props.Actor), false, false))) return blocks } func maybeCommitURL(repo string, event github.PushEvent) string { if event.HeadCommit == nil || event.HeadCommit.URL == nil { if event.Repo == nil { return "https://github.com/" + repo } return *event.Repo.URL } return *event.HeadCommit.URL } ================================================ FILE: pkg/waiter/template.go ================================================ package waiter import ( "os" ) func execTemplate(value any, mapping func(string) string) any { if value == nil { return nil } switch x := value.(type) { case map[string]any: return execMapTemplate(x, mapping) case string: return os.Expand(x, mapping) case []any: var res []any for _, y := range x { res = append(res, execTemplate(y, mapping)) } return res default: } return value } func execMapTemplate(input map[string]any, mapping func(string) string) map[string]any { if input == nil { return nil } out := map[string]any{} for key, value := range input { out[key] = execTemplate(value, mapping) } return out } ================================================ FILE: pkg/waiter/template_test.go ================================================ package waiter import ( "encoding/json" "testing" "github.com/google/go-cmp/cmp" v1 "namespacelabs.dev/breakpoint/api/private/v1" ) func TestExecTemplate(t *testing.T) { var webhook v1.Webhook if err := json.Unmarshal([]byte(`{ "url": "foobar", "payload": { "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Workflow failed", "emoji": true } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Repository:* (${GITHUB_REF_NAME})" } } ] } }`), &webhook); err != nil { t.Fatal(err) } got := execTemplate(webhook.Payload, func(str string) string { switch str { case "GITHUB_REPOSITORY": return "arepo" case "GITHUB_REF_NAME": return "main" } return "" }) if d := cmp.Diff(map[string]any{ "blocks": []any{ map[string]any{ "text": map[string]any{ "emoji": bool(true), "text": string("Workflow failed"), "type": string("plain_text"), }, "type": string("header"), }, map[string]any{ "text": map[string]any{ "text": string("*Repository:* (main)"), "type": string("mrkdwn"), }, "type": string("section"), }, }, }, got); d != "" { t.Errorf("mismatch (-want +got):\n%s", d) } } ================================================ FILE: pkg/waiter/waiter.go ================================================ package waiter import ( "context" "io" "math" "net" "os" "strings" "sync" "time" "github.com/dustin/go-humanize" "github.com/rs/zerolog" v1 "namespacelabs.dev/breakpoint/api/private/v1" "namespacelabs.dev/breakpoint/pkg/webhook" ) const ( logTickInterval = 1 * time.Minute Stamp = time.Stamp + " MST" ) type ManagerOpts struct { InitialDur time.Duration Webhooks []v1.Webhook SlackBots []v1.SlackBot } type ManagerStatus struct { Endpoint string `json:"endpoint"` Expiration time.Time `json:"expiration"` NumConnections uint32 `json:"num_connections"` } type Manager struct { ctx context.Context logger zerolog.Logger opts ManagerOpts mu sync.Mutex updated chan struct{} expiration time.Time endpoint string resources []io.Closer connectionCountCallback func() uint32 } func NewManager(ctx context.Context, opts ManagerOpts) (*Manager, context.Context) { ctx, cancel := context.WithCancel(ctx) l := zerolog.Ctx(ctx).With().Logger() m := &Manager{ ctx: ctx, logger: l, opts: opts, updated: make(chan struct{}, 1), expiration: time.Now().Add(opts.InitialDur), } go func() { defer cancel() m.loop(ctx) m.mu.Lock() resources := m.resources m.resources = nil m.mu.Unlock() // Resources should clean up quickly as they hold up the cancelation of the context. // We're guaranteed to wait for these because the incoming `ctx` is never cancelled. for _, closer := range resources { if err := closer.Close(); err != nil { l.Err(err).Msg("Failed while cleaning up resource") } } }() return m, ctx } func (m *Manager) Wait() error { <-m.ctx.Done() return m.ctx.Err() } func (m *Manager) loop(ctx context.Context) { exitTimer := time.NewTicker(time.Until(m.expiration)) defer exitTimer.Stop() logTicker := time.NewTicker(logTick()) defer logTicker.Stop() for { select { case _, ok := <-m.updated: if !ok { return } m.mu.Lock() newExp := m.expiration m.mu.Unlock() exitTimer.Reset(time.Until(newExp)) m.announce() case <-exitTimer.C: // Timer has expired, terminate the program m.logger.Info().Msg("Breakpoint expired") return case <-logTicker.C: m.announce() case <-ctx.Done(): return } } } func logTick() time.Duration { // If running in CI, announce on a regular basis. if os.Getenv("CI") != "" { return logTickInterval } return math.MaxInt64 } func (m *Manager) ExtendWait(dur time.Duration) time.Time { m.mu.Lock() defer m.mu.Unlock() m.expiration = m.expiration.Add(dur) m.updated <- struct{}{} m.logger.Info(). Dur("dur", dur). Time("expiration", m.expiration). Msg("Extend wait") return m.expiration } func (m *Manager) StopWait() { m.logger.Info().Msg("Resume requested") close(m.updated) } func (m *Manager) Expiration() time.Time { m.mu.Lock() defer m.mu.Unlock() return m.expiration } func (m *Manager) Endpoint() string { m.mu.Lock() defer m.mu.Unlock() return m.endpoint } func (m *Manager) Status() ManagerStatus { m.mu.Lock() defer m.mu.Unlock() return ManagerStatus{ Endpoint: m.endpoint, Expiration: m.expiration, NumConnections: m.connectionCountCallback(), } } func (m *Manager) SetEndpoint(addr string) { m.mu.Lock() m.endpoint = addr m.mu.Unlock() var resources []io.Closer for _, bot := range m.opts.SlackBots { if bot := startBot(m.ctx, m, bot); bot != nil { resources = append(resources, bot) } } m.mu.Lock() m.resources = resources m.mu.Unlock() m.updated <- struct{}{} expandf := expand(addr, m.Expiration()) for _, wh := range m.opts.Webhooks { ctx, done := context.WithTimeout(m.ctx, 30*time.Second) defer done() payload := execTemplate(wh.Payload, expandf) t := time.Now() if err := webhook.Notify(ctx, os.Expand(wh.URL, expandf), payload); err != nil { m.logger.Err(err).Msg("Failed to notify Webhook") } else { m.logger.Info().Dur("took", time.Since(t)).Str("url", wh.URL).Msg("Notified webhook") } } } func (m *Manager) SetConnectionCountCallback(callback func() uint32) { m.mu.Lock() m.connectionCountCallback = callback m.mu.Unlock() } func expand(addr string, exp time.Time) func(key string) string { host, port, _ := net.SplitHostPort(addr) return func(key string) string { switch key { case "BREAKPOINT_ENDPOINT": return addr case "BREAKPOINT_HOST": return host case "BREAKPOINT_PORT": return port case "BREAKPOINT_TIME_LEFT": return strings.TrimSpace(humanize.RelTime(exp, time.Now(), "", "")) case "BREAKPOINT_EXPIRATION": return exp.Format(Stamp) } return os.Getenv(key) } } func (m *Manager) announce() { status := m.Status() PrintConnectionInfo(status.Endpoint, status.Expiration, os.Stderr) } func nchars(ch rune, n int) string { str := make([]rune, n) for k := 0; k < n; k++ { str[k] = ch } return string(str) } ================================================ FILE: pkg/webhook/notifier.go ================================================ package webhook import ( "bytes" "context" "encoding/json" "net/http" "namespacelabs.dev/breakpoint/pkg/httperrors" ) const ( userAgent = "Breakpoint/1.0" ) func Notify(ctx context.Context, endpoint string, payload any) error { body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(body)) if err != nil { return err } req.Header.Set("User-Agent", userAgent) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() return httperrors.MaybeError(resp) }