├── .dockerignore ├── .github ├── slack-notification.json └── workflows │ ├── build.yml │ ├── checks.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 ├── imgs │ ├── Breakpoint high-level view.png │ └── breakpoint-banner.png └── server-setup.md ├── examples └── wait.withslack.json ├── flake.lock ├── 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 /.dockerignore: -------------------------------------------------------------------------------- 1 | fly.toml -------------------------------------------------------------------------------- /.github/slack-notification.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "${SLACK_WEBHOOK_URL}", 3 | "payload": { 4 | "blocks": [ 5 | { 6 | "type": "header", 7 | "text": { 8 | "type": "plain_text", 9 | "text": "Workflow breakpoint started", 10 | "emoji": true 11 | } 12 | }, 13 | { 14 | "type": "section", 15 | "text": { 16 | "type": "mrkdwn", 17 | "text": "*Repository:* (${GITHUB_REF_NAME})" 18 | } 19 | }, 20 | { 21 | "type": "section", 22 | "text": { 23 | "type": "mrkdwn", 24 | "text": "*Workflow:* ${GITHUB_WORKFLOW} ()" 25 | } 26 | }, 27 | { 28 | "type": "section", 29 | "text": { 30 | "type": "mrkdwn", 31 | "text": "*SSH:* `ssh -p ${BREAKPOINT_PORT} runner@${BREAKPOINT_HOST}`" 32 | } 33 | }, 34 | { 35 | "type": "section", 36 | "text": { 37 | "type": "mrkdwn", 38 | "text": "*Expires:* in ${BREAKPOINT_TIME_LEFT} (${BREAKPOINT_EXPIRATION})" 39 | } 40 | }, 41 | { 42 | "type": "context", 43 | "elements": [ 44 | { 45 | "type": "plain_text", 46 | "text": "Actor: ${GITHUB_ACTOR}", 47 | "emoji": true 48 | } 49 | ] 50 | } 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read # Checkout the code 10 | packages: write # Push to GitHub registry 11 | 12 | env: 13 | IMAGE_NAME: rendezvous 14 | IMAGE_REPO: ghcr.io/${{ github.repository_owner }} 15 | VERSION: ${{ github.sha }} 16 | 17 | jobs: 18 | docker-build: 19 | name: Build with Docker 20 | runs-on: nscloud 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Login to GitHub Container Registry 26 | uses: docker/login-action@v2 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Setup Buildx for Docker build 33 | uses: docker/setup-buildx-action@v2 34 | 35 | - name: Docker build the Rendezvous server 36 | uses: docker/build-push-action@v4 37 | with: 38 | context: . 39 | push: true 40 | tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} 41 | 42 | - name: Breakpoint on failure 43 | if: failure() 44 | uses: namespacelabs/breakpoint-action@v0 45 | env: 46 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 47 | with: 48 | duration: 30m 49 | authorized-users: edganiukov,hugosantos,n-g,htr,nichtverstehen,gmichelo 50 | slack-announce-channel: "#ci" 51 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Commit Checks 2 | on: 3 | pull_request: 4 | branches: 5 | - "*" 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read # Checkout the code 13 | 14 | jobs: 15 | checks: 16 | name: Code Checks 17 | runs-on: nscloud-ubuntu-22.04-amd64-2x8 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | - name: Install Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: 'stable' 25 | 26 | - name: Check Go formatting 27 | run: go fmt ./... && git diff --exit-code 28 | 29 | - name: Check Go mod is tidy 30 | run: go mod tidy && git diff --exit-code 31 | 32 | - name: Check that Go builds 33 | run: | 34 | go build -o . ./cmd/... 35 | 36 | - name: Breakpoint on failure 37 | if: failure() 38 | uses: namespacelabs/breakpoint-action@v0 39 | env: 40 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 41 | with: 42 | duration: 30m 43 | authorized-users: edganiukov,hugosantos,n-g,htr,nichtverstehen,gmichelo 44 | slack-announce-channel: "#ci" 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Rendezvous Docker image 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: 8 | contents: read # Checkout the code 9 | packages: write # Push to GitHub registry 10 | 11 | env: 12 | IMAGE_NAME: rendezvous 13 | IMAGE_REPO: ghcr.io/${{ github.repository_owner }} 14 | VERSION: ${{ github.event.release.tag_name }} 15 | 16 | jobs: 17 | docker-release: 18 | name: Release Docker image ${{ github.event.release.tag_name }} 19 | runs-on: nscloud-ubuntu-22.04-amd64-2x8 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | 24 | - name: Login to GitHub Container Registry 25 | uses: docker/login-action@v2 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Setup Buildx for Docker build 32 | uses: namespacelabs/nscloud-setup-buildx-action@v0 33 | 34 | - name: Docker build the Rendezvous server 35 | uses: docker/build-push-action@v4 36 | with: 37 | context: . 38 | push: true 39 | tags: ${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} 40 | platforms: linux/amd64,linux/arm64 41 | 42 | - name: Breakpoint on failure 43 | if: failure() 44 | uses: namespacelabs/breakpoint-action@v0 45 | env: 46 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} 47 | with: 48 | duration: 30m 49 | authorized-users: edganiukov,hugosantos,n-g,htr,nichtverstehen,gmichelo 50 | slack-announce-channel: "#ci" 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | dist/ 24 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | # You may remove this if you don't use go modules. 4 | - go mod tidy 5 | builds: 6 | - id: breakpoint 7 | main: ./cmd/breakpoint 8 | binary: breakpoint 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | - windows 15 | goarch: 16 | - amd64 17 | - arm64 18 | 19 | archives: 20 | - id: breakpoint 21 | builds: 22 | - breakpoint 23 | name_template: "breakpoint_{{ .Os }}_{{ .Arch }}" 24 | 25 | release: 26 | github: 27 | owner: namespacelabs 28 | name: breakpoint 29 | 30 | checksum: 31 | name_template: "checksums.txt" 32 | snapshot: 33 | name_template: "{{ incpatch .Version }}-next" 34 | 35 | changelog: 36 | sort: asc 37 | filters: 38 | exclude: 39 | - "^docs:" 40 | - "^test:" 41 | - "^nochangelog" 42 | - "^Merge pull request" 43 | - "^Merge branch" 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod ./ 6 | COPY go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN go build ./cmd/rendezvous 12 | 13 | FROM cgr.dev/chainguard/static 14 | 15 | COPY --from=builder /app/rendezvous /rendezvous 16 | 17 | CMD [ "/rendezvous" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Breakpoint. Debug with SSH. Resume. 2 | 3 | [![Discord](https://img.shields.io/badge/Join-Namespace-blue?color=blue&label=Discord&logo=discord&logoColor=3eb0ff&style=flat-square)](https://discord.gg/DqMzDFR6Hc) 4 | [![Twitter Follow](https://img.shields.io/badge/Follow-Namespace_Labs-blue?logo=twitter&style=flat-square)](https://twitter.com/intent/follow?screen_name=namespacelabs) 5 | [![GitHub Actions](https://img.shields.io/badge/GitHub-Action-blue?logo=githubactions&style=flat-square)](https://github.com/namespacelabs/breakpoint-action) 6 | ![GitHub](https://img.shields.io/github/license/namespacelabs/breakpoint?color=blue&label=License&style=flat-square) 7 | ![Build](https://img.shields.io/github/actions/workflow/status/namespacelabs/breakpoint/build.yml?label=Build&style=flat-square) 8 | ![Checks](https://img.shields.io/github/actions/workflow/status/namespacelabs/breakpoint/checks.yml?label=Checks&style=flat-square) 9 | 10 | # Breakpoint 11 | 12 | Add breakpoints to CI (e.g. GitHub Action workflows): pause workflows, access the workflow with SSH, debug and resume executions. 13 | 14 | ## What is Breakpoint 15 | 16 | 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? 17 | 18 | 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). 19 | 20 | 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. 21 | 22 | And it's 100% open-source (both client and server). 23 | 24 | > ℹ️ Workflows that have active breakpoints are still "running" and continue to count towards your total CI usage. 25 | 26 | ## Using Breakpoint 27 | 28 | 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. 29 | 30 | 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". 31 | 32 | ```yaml 33 | jobs: 34 | go-tests: 35 | runs-on: ubuntu-latest 36 | 37 | permissions: 38 | id-token: write 39 | contents: read 40 | 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: Run Go tests 46 | runs: | 47 | go test ./... 48 | 49 | - name: Breakpoint if tests failed 50 | if: failure() 51 | uses: namespacelabs/breakpoint-action@v0 52 | with: 53 | duration: 30m 54 | authorized-users: jack123, alice321 55 | ``` 56 | 57 | When Breakpoint activates, it will output on a regular basis how much time left 58 | there is in the breakpoint, and which address to SSH to get to the workflow. 59 | 60 | ```bash 61 | ┌───────────────────────────────────────────────────────────────────────────┐ 62 | │ │ 63 | │ Breakpoint running until 2023-05-24T16:06:48+02:00 (29 minutes from now). │ 64 | │ │ 65 | │ Connect with: ssh -p 40812 runner@rendezvous.namespace.so │ 66 | │ │ 67 | └───────────────────────────────────────────────────────────────────────────┘ 68 | ``` 69 | 70 | You can now SSH the runner, re-run builds or tests, and even do changes. 71 | 72 | If you need more time, run `breakpoint extend` to extend the breakpoint duration 73 | by 30 more minutes (or extend by more with the `--for` flag). 74 | 75 | When you are done, you can end the breakpoint session with `breakpoint resume`. 76 | 77 | > [!TIP] 78 | > You can also run Breakpoint in the background, by adding `mode: background` to the actions inputs. \ 79 | > That way, you can connect to it at any time during your workflow 80 | > 81 | >
82 | > Example 83 | >

84 | > 85 | > ```yaml 86 | > - name: Breakpoint in the background 87 | > uses: namespacelabs/breakpoint-action@v0 88 | > with: 89 | > mode: background 90 | > authorized-users: jack123, alice321 91 | > ``` 92 | >

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