├── .github ├── .kodiak.toml ├── CODEOWNERS └── workflows │ ├── publish-latest.yml │ ├── publish.yml │ └── test.yml ├── LICENSE ├── README.md ├── client ├── auth.go ├── client.go └── doc.go ├── cmd ├── reverst │ ├── config.go │ └── main.go └── reverstd │ └── main.go ├── dagger.json ├── dagger ├── .gitattributes ├── .gitignore ├── go.mod ├── go.sum └── main.go ├── docs ├── PROTOCOL.md ├── diagram.d2 ├── diagram.png └── gopher-glasses.svg ├── examples └── simple │ ├── README.md │ ├── config.yml │ ├── group.yml │ ├── server.crt │ └── server.key ├── go.mod ├── go.sum ├── go.work ├── go.work.sum ├── internal ├── auth │ ├── auth.go │ └── auth_test.go ├── config │ ├── config.go │ ├── fsnotify.go │ ├── k8s.go │ └── k8s_test.go ├── roundrobbin │ ├── set.go │ └── set_test.go ├── server │ ├── metrics.go │ └── server.go ├── synctyped │ └── synctyped.go └── test │ └── integration_test.go └── pkg └── protocol ├── protocol.go └── responsecode_string.go /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | # .kodiak.toml 2 | version = 1 3 | 4 | [approve] 5 | auto_approve_usernames = ["dependabot"] 6 | 7 | [update] 8 | always = true 9 | 10 | [merge] 11 | method = "squash" 12 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # @global-owner1 and @global-owner2 will be requested for 4 | # review when someone opens a pull request. 5 | * @flipt-io/maintainers -------------------------------------------------------------------------------- /.github/workflows/publish-latest.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish Latest' 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | # limit concurrency of workflow to one run at a time 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | 12 | jobs: 13 | publish-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | steps: 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: Login to GitHub Container Registry 23 | uses: docker/login-action@v3 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.repository_owner }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Tag current SHA being pushed to main as latest 30 | run: | 31 | docker buildx imagetools create \ 32 | --tag ghcr.io/${{ github.repository }}:latest \ 33 | ghcr.io/${{ github.repository }}:${{ github.sha }} 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish' 2 | 3 | on: 4 | workflow_dispatch: 5 | merge_group: 6 | 7 | jobs: 8 | publish-image: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Call Dagger Function 17 | uses: dagger/dagger-for-github@v5 18 | with: 19 | version: "0.11.6" 20 | verb: call 21 | args: publish --source . --password env:GITHUB_TOKEN --tag ${{ github.sha }} 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'Test' 2 | 3 | on: 4 | pull_request: 5 | merge_group: 6 | 7 | jobs: 8 | unit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Call Dagger Function 14 | uses: dagger/dagger-for-github@v5 15 | with: 16 | version: "0.11.6" 17 | verb: call 18 | args: test-unit --source . 19 | 20 | integration: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | - name: Call Dagger Function 26 | uses: dagger/dagger-for-github@v5 27 | with: 28 | version: "0.11.6" 29 | verb: call 30 | args: test-integration --source . 31 | -------------------------------------------------------------------------------- /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 | reverst: HTTP reverse tunnels over QUIC 2 | --------------------------------------- 3 | 4 |

5 | Tunnel Gopher 6 |

7 | 8 | > Ti esrever dna ti pilf nwod gnaht ym tup i 9 | 10 | Reverst is a (load-balanced) reverse-tunnel server and Go server-client library built on QUIC and HTTP/3. 11 | 12 | - Go Powered: Written in Go using [quic-go](https://github.com/quic-go/quic-go) 13 | - Compatible: The Go `client` package is built on `net/http` standard-library abstractions 14 | - Load-balanced: Run multiple instances of your services behind the same tunnel 15 | - Performant: Built on top of QUIC and HTTP/3 16 | 17 | ## Use-case 18 | 19 | Reverst is for exposing services on the public internet from within restrictive networks (e.g. behind NAT gateways). 20 | The tunnel binary is intended to be deployed on the public internet. 21 | Client servers then dial out to the tunnels and register themselves on target tunnel groups. 22 | A tunnel group is a load-balanced set of client-servers, which is exposed through the reverst tunnel HTTP interface. 23 | 24 | ## Client 25 | 26 | [![Go Reference](https://pkg.go.dev/badge/go.flipt.io/reverst/client.svg)](https://pkg.go.dev/go.flipt.io/reverst/client) 27 | 28 | The following section refers to the Go tunnel client code. 29 | This can be added as a dependency to any Go code that requires exposing through a `reverstd` tunnel server. 30 | 31 | ### Install 32 | 33 | ```console 34 | go get go.flipt.io/reverst/client 35 | ``` 36 | 37 | ### Building 38 | 39 | ```console 40 | go install ./client/... 41 | ``` 42 | 43 | ## Server and CLI 44 | 45 | ### Building 46 | 47 | The following builds both `reverstd` (tunnel server) and `reverst` (tunnel cli client). 48 | 49 | ```console 50 | go install ./cmd/... 51 | ``` 52 | 53 | ### Testing 54 | 55 | Reverst uses Dagger to setup and run an integration test suite. 56 | 57 | #### Unit 58 | 59 | ```console 60 | dagger call testUnit --source=. 61 | ``` 62 | 63 | #### Integration 64 | 65 | ```console 66 | dagger call testIntegration --source=. 67 | ``` 68 | 69 | The test suite sets up a tunnel, registers a server-client to the tunnel and then requests the service through the tunnels HTTP interface. 70 | 71 | ### Examples 72 | 73 | Head over to the [examples](./examples) directory for some walkthroughs running `reverstd` and `reverst`. 74 | 75 | ### Usage and Configuration 76 | 77 | #### Command-Line Flags and Environment Variables 78 | 79 | The following flags can be used to configure a running instance of the `reverst` server. 80 | 81 | ```console 82 | ➜ reverstd -h 83 | COMMAND 84 | reverstd 85 | 86 | USAGE 87 | reverstd [FLAGS] 88 | 89 | FLAGS 90 | -l, --log LEVEL debug, info, warn or error (default: INFO) 91 | -a, --tunnel-address STRING address for accepting tunnelling quic connections (default: 127.0.0.1:7171) 92 | -s, --http-address STRING address for serving HTTP requests (default: 0.0.0.0:8181) 93 | -n, --server-name STRING server name used to identify tunnel via TLS (required) 94 | -k, --private-key-path STRING path to TLS private key PEM file (required) 95 | -c, --certificate-path STRING path to TLS certificate PEM file (required) 96 | -g, --tunnel-groups STRING path to file or k8s configmap identifier (default: groups.yml) 97 | -w, --watch-groups watch tunnel groups sources for updates 98 | --management-address STRING HTTP address for management API 99 | --max-idle-timeout DURATION maximum time a connection can be idle (default: 1m0s) 100 | --keep-alive-period DURATION period between keep-alive events (default: 30s) 101 | ``` 102 | 103 | The long form names of each flag can also be referenced as environment variable names. 104 | To do so, prefix them with `REVERST_`, replace each `-` with `_` and uppercase the letters. 105 | 106 | For example, `--tunnel-address` becomes `REVERST_TUNNEL_ADDRESS`. 107 | 108 | #### Tunnel Groups Configuration YAML 109 | 110 | **configuring** 111 | 112 | Currently, the tunnel groups configuration can be sourced from two different locations types (`file` and `k8s`). 113 | Both tunnel group sources support watching sources for changes over time (see `-w` flag). 114 | 115 | - Local filesystem (`file://[path]`) 116 | 117 | The standard and simplest method is to point `reverstd` at your configuration YAML file on your machine via its path. 118 | 119 | ```console 120 | reverstd -g path/to/configuration.yml 121 | // alternatively: 122 | reverstd -g file:///path/to/configuration.yml 123 | ``` 124 | 125 | - Kubernetes ConfigMap `k8s://configmap/[namespace]/[name]/[key]` 126 | 127 | Alternatively, you can configure reverst to connect to a Kubernetes API server and fetch / watch configuration from. 128 | 129 | ```console 130 | reverstd -g k8s://configmap/default/tunnelconfig/groups.yml 131 | ``` 132 | 133 | **defining** 134 | 135 | The `reverstd` server take a path to a YAML encoded file, which identifies the tunnel groups to be hosted. 136 | A tunnel group is a load-balancer on which tunneled servers can register themselves. 137 | The file contains a top-level key groups, under which each tunnel group is uniquely named. 138 | 139 | ```yaml 140 | groups: 141 | "group-name": 142 | hosts: 143 | - "some.host.address.dev" # Host for routing inbound HTTP requests to tunnel group 144 | authentication: 145 | basic: 146 | username: "user" 147 | password: "pass" 148 | ``` 149 | 150 | Each group body contains import details for configuring the tunnel groups. 151 | 152 | **hosts** 153 | 154 | This is an array of strings which is used in routing HTTP requests to the tunnel group when one of the hostnames matches. 155 | 156 | **authentication** 157 | 158 | This identifies how to authenticate new tunnels attempting to register with the group. 159 | Multiple authentication strategies can be enabled at once. 160 | The following types are supported: 161 | 162 | - `basic` supports username and password authentication (default scheme `Basic`) 163 | - `bearer` supports static token based matching (default scheme `Bearer`) 164 | - `external` supports offloading authentication and authorization to an external service (default scheme `Bearer`) 165 | 166 | > [!Note] 167 | > If enabling both `bearer` and `external` you will need to override one of their schemes to distinguish them. 168 | 169 |
170 | 171 | Example configuration with multiple authentication strategies 172 | 173 | The following contains all three strategies (basic, bearer and external) enabled at once with different schemes: 174 | 175 | ```yaml 176 | groups: 177 | "group-name": 178 | hosts: 179 | - "some.host.address.dev" # Host for routing inbound HTTP requests to tunnel group 180 | authentication: 181 | basic: 182 | username: "user" 183 | password: "pass" 184 | bearer: 185 | token: "some-token" 186 | external: 187 | scheme: "JWT" 188 | endpoint: "http://some-external-endpoint/auth/ext" 189 | ``` 190 | 191 |
192 | 193 | If no strategies are supplied then authentication is disabled (strongly discouraged). 194 | -------------------------------------------------------------------------------- /client/auth.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "log/slog" 8 | 9 | "go.flipt.io/reverst/pkg/protocol" 10 | ) 11 | 12 | const authorizationMetadataKey = "Authorization" 13 | 14 | // Authenticator is a type which adds authentication credentials to an outbound 15 | // register listener request. 16 | // It is called before the request is serialized and written to the stream. 17 | type Authenticator interface { 18 | Authenticate(context.Context, *protocol.RegisterListenerRequest) error 19 | } 20 | 21 | // AuthenticatorFunc is a function which implements the Authenticator interface 22 | type AuthenticatorFunc func(context.Context, *protocol.RegisterListenerRequest) error 23 | 24 | // Authenticate delegates to the underlying AuthenticatorFunc 25 | func (a AuthenticatorFunc) Authenticate(ctx context.Context, r *protocol.RegisterListenerRequest) error { 26 | return a(ctx, r) 27 | } 28 | 29 | var defaultAuthenticator Authenticator = AuthenticatorFunc(func(ctx context.Context, rlr *protocol.RegisterListenerRequest) error { 30 | slog.Warn("No authenticator provided, attempting to register connection without credentials") 31 | return nil 32 | }) 33 | 34 | type AuthenticatorOptions struct { 35 | scheme string 36 | } 37 | 38 | type AuthorizationOption func(*AuthenticatorOptions) 39 | 40 | func WithScheme(scheme string) AuthorizationOption { 41 | return func(ao *AuthenticatorOptions) { 42 | ao.scheme = scheme 43 | } 44 | } 45 | 46 | // BasicAuthenticator returns an instance of Authenticator which configures Basic authentication 47 | // on requests passed to Authenticate using the provided username and password 48 | func BasicAuthenticator(username, password string, opts ...AuthorizationOption) Authenticator { 49 | options := AuthenticatorOptions{scheme: "Basic"} 50 | for _, opt := range opts { 51 | opt(&options) 52 | } 53 | 54 | return AuthenticatorFunc(func(ctx context.Context, rlr *protocol.RegisterListenerRequest) error { 55 | if rlr.Metadata == nil { 56 | rlr.Metadata = map[string]string{} 57 | } 58 | 59 | rlr.Metadata[authorizationMetadataKey] = fmt.Sprintf("%s %s", 60 | options.scheme, 61 | base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))), 62 | ) 63 | 64 | return nil 65 | }) 66 | } 67 | 68 | // BearerAuthenticator returns an instance of Authenticator which configures Bearer authentication 69 | // on requests passed to Authenticate using the provided token string 70 | func BearerAuthenticator(token string, opts ...AuthorizationOption) Authenticator { 71 | options := AuthenticatorOptions{scheme: "Bearer"} 72 | for _, opt := range opts { 73 | opt(&options) 74 | } 75 | 76 | return AuthenticatorFunc(func(ctx context.Context, rlr *protocol.RegisterListenerRequest) error { 77 | if rlr.Metadata == nil { 78 | rlr.Metadata = map[string]string{} 79 | } 80 | 81 | rlr.Metadata[authorizationMetadataKey] = fmt.Sprintf("%s %s", options.scheme, token) 82 | 83 | return nil 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "time" 14 | 15 | "github.com/quic-go/quic-go" 16 | "github.com/quic-go/quic-go/http3" 17 | "go.flipt.io/reverst/pkg/protocol" 18 | "k8s.io/apimachinery/pkg/util/wait" 19 | ) 20 | 21 | var ( 22 | // DefaultTLSConfig is the default configuration used for establishing 23 | // TLS over QUIC. 24 | DefaultTLSConfig = &tls.Config{ 25 | NextProtos: []string{protocol.Name}, 26 | } 27 | // DefaultQuicConfig is the default configuration used for establishing 28 | // QUIC connections. 29 | DefaultQuicConfig = &quic.Config{ 30 | MaxIdleTimeout: 20 * time.Second, 31 | KeepAlivePeriod: 10 * time.Second, 32 | } 33 | 34 | // DefaultBackoff is the default backoff used when dialing and serving 35 | // a connection. 36 | DefaultBackoff = wait.Backoff{ 37 | Steps: 5, 38 | Duration: 100 * time.Millisecond, 39 | Factor: 2.0, 40 | Jitter: 0.1, 41 | } 42 | 43 | // ErrNotFound is returned when a tunnel group is referenced that the 44 | // target reverst tunnel server does not know (CodeNotFound) 45 | ErrNotFound = errors.New("not found") 46 | // ErrBadRequest is returned when a tunnel registration request is rejected 47 | // due to an unexpected request payload (CodeBadRequest) 48 | ErrBadRequest = errors.New("bad request") 49 | // ErrUnauthorized is returned when the caller is not properly authenticated to 50 | // establish a tunnel on the request tunnel group (CodeUnauthorized) 51 | ErrUnauthorized = errors.New("unauthorized") 52 | // ErrServerError is returned when something unexplained went wrong on the 53 | // remote reverst tunnel server (CodeServerError) 54 | ErrServerError = errors.New("server error") 55 | ) 56 | 57 | // Server is an alternative HTTP server that dials to a reverst Tunnel server 58 | // and attempts to remotely register itself as a listener. 59 | // Given the connection is established and authorized as a valid listener, the 60 | // server switches into serving mode and handles HTTP/3 requests over the connection. 61 | // The Tunnel should forward requests to this connection and any others in the 62 | // same tunnel group. The group is identified via the TLSConfig.ServerName. 63 | type Server struct { 64 | // TunnelGroup is an identifier for the group in which this server should 65 | // be registered against on the target tunnel server. 66 | TunnelGroup string 67 | 68 | // Handler is the root http.Handler of the server instance. 69 | Handler http.Handler 70 | 71 | // Logger allows the caller to configure a custome *slog.Logger instance. 72 | // If not defined then Server uses the default instance returned by slog.Default. 73 | Logger *slog.Logger 74 | 75 | // TLSConfig is used to configure TLS encryption over the Quic connection. 76 | // See DefaultTLSConfig for the parameters used which this is set to nil. 77 | TLSConfig *tls.Config 78 | 79 | // QuicConfig is used to configure Quic connections. 80 | // See DefaultQuicConfig for the parameters used which this is set to nil. 81 | QuicConfig *quic.Config 82 | 83 | // Authenticator is the Authenticator used to authenticate outbound 84 | // listener registration requests. 85 | Authenticator Authenticator 86 | 87 | // OnConnectionReady is called when the server has successfully 88 | // registered itself with the upstream tunnel server 89 | OnConnectionReady func(protocol.RegisterListenerResponse) 90 | } 91 | 92 | func coallesce[T any](v, d *T) *T { 93 | if v == nil { 94 | return d 95 | } 96 | 97 | return v 98 | } 99 | 100 | func (s *Server) getTLSConfig(addr string) (*tls.Config, error) { 101 | tlsConf := coallesce(s.TLSConfig, DefaultTLSConfig) 102 | if tlsConf.ServerName == "" { 103 | // if the TLS ServerName is not explicitly supplied 104 | // then we will parse the dial address and use the hostname 105 | // defined on that instead 106 | url, err := url.Parse(addr) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | tlsConf.ServerName = url.Hostname() 112 | } 113 | 114 | return tlsConf, nil 115 | } 116 | 117 | // DialAndServe dials out to the provided address and attempts to register the server 118 | // as a listener on the remote tunnel group. 119 | func (s *Server) DialAndServe(ctx context.Context, addr string) (err error) { 120 | attrs := []slog.Attr{slog.String("addr", addr)} 121 | if host, port, err := net.SplitHostPort(addr); err == nil { 122 | attrs = []slog.Attr{slog.String("host", host), slog.String("port", port)} 123 | } 124 | 125 | log := slog.New(coallesce(s.Logger, slog.Default()).Handler().WithAttrs(attrs)) 126 | log.Debug("Dialing tunnel") 127 | 128 | var lastErr error 129 | err = wait.ExponentialBackoffWithContext(ctx, DefaultBackoff, func(context.Context) (done bool, err error) { 130 | err = s.dialAndServe(ctx, log, addr) 131 | if err != nil { 132 | lastErr = err 133 | if errors.Is(err, context.Canceled) { 134 | return false, nil 135 | } 136 | 137 | // these errors are considered non-recoverable 138 | // not-found is considered recoverable under the situation that the 139 | // tunnel has recently been requested for provisioning and is coming online 140 | if errors.Is(err, ErrUnauthorized) || 141 | errors.Is(err, ErrBadRequest) { 142 | return false, err 143 | } 144 | 145 | // we log out the error under debug as this function will be repeated 146 | // and hopefully will eventually succeed 147 | // if not then the last observed error should be returned and logged 148 | // at a higher log level 149 | log.Debug("Error while attempting to dial and register", "error", err) 150 | 151 | return false, nil 152 | } 153 | 154 | return true, nil 155 | }) 156 | 157 | // this signifies that the exponential backoff was exhausted or exceeded a deadline 158 | // in this situation we simply return the last observed error in the dial and serve attempts 159 | if !errors.Is(err, context.Canceled) && wait.Interrupted(err) { 160 | err = lastErr 161 | } 162 | 163 | return err 164 | } 165 | 166 | func (s *Server) dialAndServe( 167 | ctx context.Context, 168 | log *slog.Logger, 169 | addr string, 170 | ) (err error) { 171 | defer func() { 172 | if err != nil { 173 | err = fmt.Errorf("dialing and registering connection: %w", err) 174 | } 175 | }() 176 | 177 | tlsConf, err := s.getTLSConfig(addr) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | conn, err := quic.DialAddr(ctx, 183 | addr, 184 | tlsConf, 185 | coallesce(s.QuicConfig, DefaultQuicConfig), 186 | ) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | go func() { 192 | <-ctx.Done() 193 | 194 | _ = conn.CloseWithError(protocol.ApplicationOK, "") 195 | }() 196 | 197 | log.Debug("Attempting to register") 198 | 199 | for { 200 | // register server as a listener on remote tunnel 201 | err := s.register(conn) 202 | if err == nil { 203 | break 204 | } 205 | 206 | // in the event a stream was closed unexpectedly while handling 207 | // registration then the likelihood is that the server went away 208 | // and will ultimately close the connection with the reason 209 | // expressed in the application error code and message 210 | // so we instead re-attempt registration in this scenario and 211 | // let it fail attempting to open another stream with the 212 | // connections application error instead 213 | if errors.Is(err, io.ErrUnexpectedEOF) { 214 | continue 215 | } 216 | 217 | message := "unexpected error" 218 | var aerr *quic.ApplicationError 219 | if errors.As(err, &aerr) { 220 | message = aerr.ErrorMessage 221 | switch aerr.ErrorCode { 222 | case protocol.ApplicationError: 223 | err = ErrServerError 224 | case protocol.ApplicationClientError: 225 | message = "client error" 226 | switch aerr.ErrorMessage { 227 | case "unauthorized": 228 | err = ErrUnauthorized 229 | case "not found": 230 | err = ErrNotFound 231 | case "bad request": 232 | err = ErrBadRequest 233 | } 234 | } 235 | } 236 | 237 | return fmt.Errorf("%s: %w", message, err) 238 | } 239 | 240 | log.Info("Starting reverse server") 241 | 242 | return (&http3.Server{Handler: s.Handler}).ServeQUICConn(conn) 243 | } 244 | 245 | func (s *Server) register(conn quic.Connection) error { 246 | stream, err := conn.OpenStream() 247 | if err != nil { 248 | return fmt.Errorf("opening stream: %w", err) 249 | } 250 | 251 | defer stream.Close() 252 | 253 | enc := protocol.NewEncoder[protocol.RegisterListenerRequest](stream) 254 | defer enc.Close() 255 | 256 | req := &protocol.RegisterListenerRequest{ 257 | Version: protocol.Version, 258 | TunnelGroup: s.TunnelGroup, 259 | } 260 | 261 | auth := defaultAuthenticator 262 | if s.Authenticator != nil { 263 | auth = s.Authenticator 264 | } 265 | 266 | if err := auth.Authenticate(stream.Context(), req); err != nil { 267 | return fmt.Errorf("registering new connection: %w", err) 268 | } 269 | 270 | if err := enc.Encode(req); err != nil { 271 | return fmt.Errorf("encoding register listener request: %w", err) 272 | } 273 | 274 | dec := protocol.NewDecoder[protocol.RegisterListenerResponse](stream) 275 | defer dec.Close() 276 | 277 | resp, err := dec.Decode() 278 | if err != nil { 279 | // EOF is not expected at this point, so we adapt it 280 | // The calling code is expected to introspect into 281 | // the state of the connection at this point 282 | if errors.Is(err, io.EOF) { 283 | err = io.ErrUnexpectedEOF 284 | } 285 | 286 | return fmt.Errorf("decoding register listener response: %w", err) 287 | } 288 | 289 | if s.OnConnectionReady != nil { 290 | s.OnConnectionReady(resp) 291 | } 292 | 293 | return nil 294 | } 295 | -------------------------------------------------------------------------------- /client/doc.go: -------------------------------------------------------------------------------- 1 | // package client 2 | // 3 | // The client package contains the client-side types for interfacing with reverst tunnels. 4 | // The client itself is a http Server implementation that dials out to a tunnel server, performs 5 | // a handshake to identify and authenticate the relevant tunnel group to register with, and then 6 | // it switches roles into that of the server. 7 | // 8 | // # Example 9 | // 10 | // package main 11 | // 12 | // import ( 13 | // "context" 14 | // "crypto/tls" 15 | // "net/http" 16 | // 17 | // "go.flipt.io/reverst/client" 18 | // ) 19 | // 20 | // func main() { 21 | // server := &client.Server { 22 | // TunnelGroup: "some-group", 23 | // Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request { 24 | // w.Write([]byte("Hello, World!")) 25 | // })), 26 | // TLSConfig: &tls.Config{InsecureSkipVerify: true} 27 | // } 28 | // 29 | // server.DialAndServe(ctx, "some.reverst.tunnel:8443") 30 | // } 31 | package client 32 | -------------------------------------------------------------------------------- /cmd/reverst/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "go.flipt.io/reverst/internal/config" 8 | ) 9 | 10 | type Config struct { 11 | Level config.Level `ff:" short=l | long=log | default=info | usage: 'debug, info, warn or error' "` 12 | TunnelAddress string `ff:" short=a | long=tunnel-address | default='127.0.0.1:7171' | usage: address for accepting tunnelling quic connections "` 13 | ServerName string `ff:" short=n | long=server-name | usage: server name used to identify tunnel via TLS (required) "` 14 | TunnelGroup string `ff:" short=g | long=tunnel-group | usage: tunnel group to join (defaults to server name) "` 15 | CACertificatePath string `ff:" short=c | long=cacert-path | usage: path to TLS CA certificate PEM file "` 16 | InsecureSkipVerify bool `ff:" | long=insecure | default=false | usage: skip TLS certficate verification "` 17 | 18 | Username string `ff:" long=username | usage: username for basic authentication "` 19 | Password string `ff:" long=password | usage: password for basic authentication "` 20 | Token string `ff:" long=token | usage: token for bearer authentication "` 21 | Scheme string `ff:" long=scheme | usage: optionally override auth scheme "` 22 | 23 | MaxIdleTimeout time.Duration `ff:" long=max-idle-timeout | default=1m | usage: maximum time a connection can be idle "` 24 | KeepAlivePeriod time.Duration `ff:" long=keep-alive-period | default=30s | usage: period between keep-alive events "` 25 | } 26 | 27 | func (c Config) Validate() error { 28 | if c.ServerName == "" { 29 | return errors.New("server-name must be non-empty string") 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /cmd/reverst/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "net/http" 11 | "net/http/httputil" 12 | "net/url" 13 | "os" 14 | "os/signal" 15 | "strings" 16 | "syscall" 17 | 18 | "github.com/peterbourgon/ff/v4" 19 | "github.com/peterbourgon/ff/v4/ffhelp" 20 | "github.com/peterbourgon/ff/v4/ffyaml" 21 | "github.com/quic-go/quic-go" 22 | "go.flipt.io/reverst/client" 23 | "go.flipt.io/reverst/pkg/protocol" 24 | ) 25 | 26 | func main() { 27 | flags := ff.NewFlagSet("reverst") 28 | _ = flags.StringLong("config", "config.yml", "path to config file") 29 | 30 | var conf Config 31 | if err := flags.AddStruct(&conf); err != nil { 32 | panic(err) 33 | } 34 | 35 | httpcmd := &ff.Command{ 36 | Name: "http", 37 | Usage: "http [FLAGS] [ADDR]", 38 | Flags: flags, 39 | Exec: func(ctx context.Context, args []string) error { 40 | logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 41 | Level: slog.Level(conf.Level), 42 | })) 43 | 44 | slog.SetDefault(logger) 45 | 46 | if err := conf.Validate(); err != nil { 47 | return err 48 | } 49 | 50 | addr := args[0] 51 | if !strings.Contains(addr, ":") { 52 | addr = fmt.Sprintf("http://0.0.0.0:%s", addr) 53 | } 54 | 55 | targetURL, err := url.Parse(addr) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | tlsConf := &tls.Config{ 61 | MinVersion: tls.VersionTLS13, 62 | NextProtos: []string{protocol.Name}, 63 | ServerName: conf.ServerName, 64 | InsecureSkipVerify: conf.InsecureSkipVerify, 65 | } 66 | 67 | tlsConf.RootCAs, _ = x509.SystemCertPool() 68 | if tlsConf.RootCAs == nil { 69 | tlsConf.RootCAs = x509.NewCertPool() 70 | } 71 | 72 | if conf.CACertificatePath != "" { 73 | caCertRaw, err := os.ReadFile(conf.CACertificatePath) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | if !tlsConf.RootCAs.AppendCertsFromPEM(caCertRaw) { 79 | return fmt.Errorf("failed to append cert at path: %q", conf.CACertificatePath) 80 | } 81 | } 82 | 83 | tunnelGroup := conf.TunnelGroup 84 | if tunnelGroup == "" { 85 | tunnelGroup = conf.ServerName 86 | } 87 | 88 | logger = logger.With( 89 | "tunnel_group", tunnelGroup, 90 | "server_name", conf.ServerName, 91 | "target", targetURL, 92 | ) 93 | 94 | var ( 95 | auth client.Authenticator 96 | scheme = conf.Scheme 97 | ) 98 | if conf.Username != "" { 99 | if scheme == "" { 100 | scheme = "Basic" 101 | } 102 | 103 | auth = client.BasicAuthenticator( 104 | conf.Username, 105 | conf.Password, 106 | client.WithScheme(scheme), 107 | ) 108 | } else if conf.Token != "" { 109 | if scheme == "" { 110 | scheme = "Bearer" 111 | } 112 | 113 | auth = client.BearerAuthenticator(conf.Token, client.WithScheme(scheme)) 114 | } 115 | 116 | return (&client.Server{ 117 | TunnelGroup: tunnelGroup, 118 | Handler: httputil.NewSingleHostReverseProxy(targetURL), 119 | Logger: logger, 120 | Authenticator: auth, 121 | TLSConfig: tlsConf, 122 | QuicConfig: &quic.Config{ 123 | MaxIdleTimeout: conf.MaxIdleTimeout, 124 | KeepAlivePeriod: conf.KeepAlivePeriod, 125 | }, 126 | }).DialAndServe(ctx, conf.TunnelAddress) 127 | }, 128 | } 129 | 130 | cmd := &ff.Command{ 131 | Name: "reverst", 132 | Usage: "reverst [FLAGS] [COMMAND]", 133 | Subcommands: []*ff.Command{ 134 | httpcmd, 135 | }, 136 | } 137 | 138 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 139 | go func() { 140 | <-ctx.Done() 141 | stop() 142 | }() 143 | 144 | if err := cmd.ParseAndRun(ctx, os.Args[1:], 145 | ff.WithEnvVarPrefix("REVERST"), 146 | ff.WithConfigFileFlag("config"), 147 | ff.WithConfigFileParser(ffyaml.Parse), 148 | ff.WithConfigAllowMissingFile(), 149 | ); err != nil { 150 | if errors.Is(err, http.ErrServerClosed) { 151 | return 152 | } 153 | 154 | fmt.Fprintf(os.Stderr, "%s\n", ffhelp.Command(cmd)) 155 | if !errors.Is(err, ff.ErrHelp) { 156 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 157 | } 158 | 159 | os.Exit(1) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /cmd/reverstd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/peterbourgon/ff/v4" 14 | "github.com/peterbourgon/ff/v4/ffhelp" 15 | "go.flipt.io/reverst/internal/config" 16 | "go.flipt.io/reverst/internal/server" 17 | ) 18 | 19 | func main() { 20 | flags := ff.NewFlagSet("reverstd") 21 | 22 | var conf config.Config 23 | if err := flags.AddStruct(&conf); err != nil { 24 | panic(err) 25 | } 26 | 27 | cmd := &ff.Command{ 28 | Name: "reverstd", 29 | Usage: "reverstd [FLAGS]", 30 | Flags: flags, 31 | Exec: func(ctx context.Context, args []string) error { 32 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 33 | Level: slog.Level(conf.Level), 34 | }))) 35 | 36 | if err := conf.Validate(); err != nil { 37 | return err 38 | } 39 | 40 | // start a subscription for tunnel group configuration 41 | // this function should push at-least one tunnel groups 42 | // instance on the channel before returning a non-nil error 43 | groupsChan := make(chan *config.TunnelGroups, 1) 44 | if err := conf.SubscribeTunnelGroups(ctx, groupsChan); err != nil { 45 | return err 46 | } 47 | 48 | server, err := server.New(conf, groupsChan) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return server.ListenAndServe(ctx) 54 | }, 55 | } 56 | 57 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 58 | go func() { 59 | <-ctx.Done() 60 | stop() 61 | }() 62 | 63 | if err := cmd.ParseAndRun(ctx, os.Args[1:], 64 | ff.WithEnvVarPrefix("REVERST"), 65 | ); err != nil { 66 | if errors.Is(err, http.ErrServerClosed) { 67 | return 68 | } 69 | 70 | fmt.Fprintf(os.Stderr, "%s\n", ffhelp.Command(cmd)) 71 | if !errors.Is(err, ff.ErrHelp) { 72 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 73 | } 74 | 75 | os.Exit(1) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /dagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reverst", 3 | "sdk": "go", 4 | "dependencies": [ 5 | { 6 | "name": "go", 7 | "source": "github.com/vito/daggerverse/go@f7223d2d82fb91622cbb7954177388d995a98d59" 8 | } 9 | ], 10 | "source": "dagger", 11 | "engineVersion": "v0.11.6" 12 | } 13 | -------------------------------------------------------------------------------- /dagger/.gitattributes: -------------------------------------------------------------------------------- 1 | /dagger.gen.go linguist-generated 2 | /internal/dagger/** linguist-generated 3 | /internal/querybuilder/** linguist-generated 4 | /internal/telemetry/** linguist-generated 5 | -------------------------------------------------------------------------------- /dagger/.gitignore: -------------------------------------------------------------------------------- 1 | /dagger.gen.go 2 | /internal/dagger 3 | /internal/querybuilder 4 | /internal/telemetry 5 | -------------------------------------------------------------------------------- /dagger/go.mod: -------------------------------------------------------------------------------- 1 | module dagger/reverst 2 | 3 | go 1.21.7 4 | 5 | require ( 6 | github.com/99designs/gqlgen v0.17.44 7 | github.com/Khan/genqlient v0.7.0 8 | github.com/vektah/gqlparser/v2 v2.5.11 9 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 10 | golang.org/x/sync v0.7.0 11 | ) 12 | 13 | require ( 14 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 15 | github.com/go-logr/logr v1.4.1 // indirect 16 | github.com/go-logr/stdr v1.2.2 // indirect 17 | github.com/google/uuid v1.6.0 // indirect 18 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect 19 | github.com/sosodev/duration v1.2.0 // indirect 20 | go.opentelemetry.io/otel v1.26.0 21 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 // indirect 22 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 23 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0 24 | go.opentelemetry.io/otel/metric v1.26.0 // indirect 25 | go.opentelemetry.io/otel/sdk v1.26.0 26 | go.opentelemetry.io/otel/trace v1.26.0 27 | go.opentelemetry.io/proto/otlp v1.2.0 // indirect 28 | golang.org/x/net v0.23.0 // indirect 29 | golang.org/x/sys v0.19.0 // indirect 30 | golang.org/x/text v0.14.0 // indirect 31 | google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect 32 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect 33 | google.golang.org/grpc v1.63.2 34 | google.golang.org/protobuf v1.33.0 // indirect 35 | ) 36 | 37 | replace go.flipt.io/reverst => ../ 38 | -------------------------------------------------------------------------------- /dagger/go.sum: -------------------------------------------------------------------------------- 1 | github.com/99designs/gqlgen v0.17.44 h1:OS2wLk/67Y+vXM75XHbwRnNYJcbuJd4OBL76RX3NQQA= 2 | github.com/99designs/gqlgen v0.17.44/go.mod h1:UTCu3xpK2mLI5qcMNw+HKDiEL77it/1XtAjisC4sLwM= 3 | github.com/Khan/genqlient v0.7.0 h1:GZ1meyRnzcDTK48EjqB8t3bcfYvHArCUUvgOwpz1D4w= 4 | github.com/Khan/genqlient v0.7.0/go.mod h1:HNyy3wZvuYwmW3Y7mkoQLZsa/R5n5yIRajS1kPBvSFM= 5 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 6 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 7 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 8 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 12 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 13 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 14 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 15 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 16 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 17 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 18 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 19 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 20 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= 21 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 25 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 26 | github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us= 27 | github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= 28 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 29 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 30 | github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= 31 | github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= 32 | go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= 33 | go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= 34 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 h1:1u/AyyOqAWzy+SkPxDpahCNZParHV8Vid1RnI2clyDE= 35 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0/go.mod h1:z46paqbJ9l7c9fIPCXTqTGwhQZ5XoTIsfeFYWboizjs= 36 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 h1:Waw9Wfpo/IXzOI8bCB7DIk+0JZcqqsyn1JFnAc+iam8= 37 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0/go.mod h1:wnJIG4fOqyynOnnQF/eQb4/16VlX2EJAHhHgqIqWfAo= 38 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0 h1:1wp/gyxsuYtuE/JFxsQRtcCDtMrO2qMvlfXALU5wkzI= 39 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0/go.mod h1:gbTHmghkGgqxMomVQQMur1Nba4M0MQ8AYThXDUjsJ38= 40 | go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= 41 | go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= 42 | go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8= 43 | go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs= 44 | go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= 45 | go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= 46 | go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= 47 | go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= 48 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 49 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 50 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 51 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 52 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 53 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 54 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 55 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 56 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 57 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 59 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 60 | google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= 61 | google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= 62 | google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= 63 | google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= 64 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= 65 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= 66 | google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= 67 | google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= 68 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 69 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 70 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 71 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | -------------------------------------------------------------------------------- /dagger/main.go: -------------------------------------------------------------------------------- 1 | // A generated module for Reverst functions 2 | // 3 | // This module has been generated via dagger init and serves as a reference to 4 | // basic module structure as you get started with Dagger. 5 | // 6 | // Two functions have been pre-created. You can modify, delete, or add to them, 7 | // as needed. They demonstrate usage of arguments and return types using simple 8 | // echo and grep commands. The functions can be called from the dagger CLI or 9 | // from one of the SDKs. 10 | // 11 | // The first line in this comment block is a short description line and the 12 | // rest is a long description with more detail on the module's purpose or usage, 13 | // if appropriate. All modules should have a short description. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "crypto/rand" 20 | "crypto/rsa" 21 | "crypto/x509" 22 | "crypto/x509/pkix" 23 | "dagger/reverst/internal/dagger" 24 | "encoding/pem" 25 | "fmt" 26 | "math/big" 27 | "time" 28 | ) 29 | 30 | const ( 31 | goBuildCachePath = "/root/.cache/go-build" 32 | goModCachePath = "/go/pkg/mod" 33 | ) 34 | 35 | type Reverst struct{} 36 | 37 | // Returns a built container with reverst on the path 38 | func (m *Reverst) BuildContainer( 39 | ctx context.Context, 40 | source *dagger.Directory, 41 | ) (*Container, error) { 42 | build := dag. 43 | Go(). 44 | FromVersion("1.22-alpine3.18"). 45 | Build(source, dagger.GoBuildOpts{ 46 | Packages: []string{"./cmd/reverstd/..."}, 47 | }) 48 | 49 | return dag. 50 | Container(). 51 | From("alpine:3.18"). 52 | WithFile("/usr/local/bin/reverstd", build.File("reverstd")). 53 | WithEntrypoint([]string{"reverstd"}), nil 54 | } 55 | 56 | func (m *Reverst) TestUnit( 57 | ctx context.Context, 58 | source *dagger.Directory, 59 | ) (string, error) { 60 | out, err := dag.Container(). 61 | From("golang:1.22-alpine3.18"). 62 | WithExec([]string{"apk", "add", "gcc", "build-base"}). 63 | With(dag.Go().GlobalCache). 64 | WithEnvVariable("CGO_ENABLED", "1"). 65 | WithMountedDirectory("/src", source). 66 | WithWorkdir("/src"). 67 | WithExec([]string{"go", "test", "-race", "-count=5", "./..."}). 68 | Stdout(ctx) 69 | if err != nil { 70 | return out, err 71 | } 72 | 73 | return out, nil 74 | } 75 | 76 | func (m *Reverst) TestIntegration( 77 | ctx context.Context, 78 | source *dagger.Directory, 79 | //+optional 80 | verbose bool, 81 | ) (string, error) { 82 | ctr, err := m.BuildContainer(ctx, source) 83 | if err != nil { 84 | return "", err 85 | } 86 | 87 | key, cert, err := generateKeyPair() 88 | if err != nil { 89 | return "", err 90 | } 91 | 92 | reverst := ctr. 93 | WithEnvVariable("REVERST_LOG", "debug"). 94 | WithEnvVariable("REVERST_TUNNEL_ADDRESS", "0.0.0.0:7171"). 95 | WithEnvVariable("REVERST_SERVER_NAME", "local.example"). 96 | WithNewFile("/etc/reverst/key.pem", dagger.ContainerWithNewFileOpts{ 97 | Contents: string(key), 98 | }). 99 | WithEnvVariable("REVERST_PRIVATE_KEY_PATH", "/etc/reverst/key.pem"). 100 | WithNewFile("/etc/reverst/cert.pem", dagger.ContainerWithNewFileOpts{ 101 | Contents: string(cert), 102 | }). 103 | WithEnvVariable("REVERST_CERTIFICATE_PATH", "/etc/reverst/cert.pem"). 104 | WithNewFile("/etc/reverst/groups.yml", dagger.ContainerWithNewFileOpts{ 105 | Contents: `groups: 106 | "local.example": 107 | hosts: ["local.example"] 108 | authentication: 109 | basic: 110 | username: "user" 111 | password: "pass" 112 | `, 113 | }). 114 | WithEnvVariable("REVERST_TUNNEL_GROUPS", "/etc/reverst/groups.yml"). 115 | WithEnvVariable("REVERST_WATCH_GROUPS", "true"). 116 | WithExposedPort(7171, dagger.ContainerWithExposedPortOpts{ 117 | Protocol: dagger.Udp, 118 | }). 119 | WithExposedPort(8181, dagger.ContainerWithExposedPortOpts{ 120 | Protocol: dagger.Tcp, 121 | }). 122 | WithExec(nil). 123 | AsService() 124 | 125 | cmd := []string{"go", "test", "./internal/test/...", "-integration"} 126 | if verbose { 127 | cmd = append(cmd, "-v") 128 | } 129 | 130 | out, err := dag.Container(). 131 | From("golang:1.22-alpine3.18"). 132 | WithServiceBinding("local.example", reverst). 133 | With(dag.Go().GlobalCache). 134 | WithMountedDirectory("/src", source). 135 | WithWorkdir("/src"). 136 | WithExec(cmd). 137 | Stdout(ctx) 138 | if err != nil { 139 | return out, err 140 | } 141 | 142 | return out, nil 143 | } 144 | 145 | func (m *Reverst) Publish( 146 | ctx context.Context, 147 | source *dagger.Directory, 148 | password *Secret, 149 | //+optional 150 | //+default="ghcr.io" 151 | registry string, 152 | //+optional 153 | //+default="flipt-io" 154 | username string, 155 | //+optional 156 | //+default="reverst" 157 | image string, 158 | //+optional 159 | //+default="latest" 160 | tag string, 161 | ) (string, error) { 162 | ctr, err := m.BuildContainer(ctx, source) 163 | if err != nil { 164 | return "", err 165 | } 166 | 167 | return ctr. 168 | WithRegistryAuth(registry, username, password). 169 | Publish(ctx, fmt.Sprintf("%s/%s/%s:%s", registry, username, image, tag)) 170 | } 171 | 172 | func generateKeyPair() ([]byte, []byte, error) { 173 | key, err := rsa.GenerateKey(rand.Reader, 2048) 174 | if err != nil { 175 | return nil, nil, err 176 | } 177 | 178 | keyPem := pem.EncodeToMemory(&pem.Block{ 179 | Type: "RSA PRIVATE KEY", 180 | Bytes: x509.MarshalPKCS1PrivateKey(key), 181 | }) 182 | 183 | crt := x509.Certificate{ 184 | NotBefore: time.Now(), 185 | NotAfter: time.Now().AddDate(10, 0, 0), 186 | SerialNumber: big.NewInt(1), 187 | Subject: pkix.Name{ 188 | CommonName: "Flipt", 189 | Organization: []string{"Flipt Corp."}, 190 | }, 191 | BasicConstraintsValid: true, 192 | } 193 | cert, err := x509.CreateCertificate(rand.Reader, &crt, &crt, &key.PublicKey, key) 194 | if err != nil { 195 | return nil, nil, err 196 | } 197 | 198 | // Generate a pem block with the certificate 199 | return keyPem, pem.EncodeToMemory(&pem.Block{ 200 | Type: "CERTIFICATE", 201 | Bytes: cert, 202 | }), nil 203 | } 204 | -------------------------------------------------------------------------------- /docs/PROTOCOL.md: -------------------------------------------------------------------------------- 1 | Tunnel Protocol 2 | --------------- 3 | 4 | This page aims to document the tunnel registration protocol. 5 | 6 |

7 | Tunnel Lifecycle 8 |

9 | 10 | ## Registration 11 | 12 | When a tunnel client attempts to establish itself as a listener on a target group managed by a `reverstd` tunnel server, it does the following: 13 | 14 | 1. Establishes a connection over QUIC. 15 | 2. Opens an initial stream intended for registration handshake. 16 | 3. Builds a [`RegisterListenerRequest`](https://pkg.go.dev/go.flipt.io/reverst@v0.1.7/pkg/protocol#RegisterListenerRequest). 17 | 4. Encodes this onto the stream using msgpack. 18 | 5. Reads back a response. 19 | 6. Closes the stream. 20 | 7. Starts a `http3.Server` over the existing QUIC connection. 21 | -------------------------------------------------------------------------------- /docs/diagram.d2: -------------------------------------------------------------------------------- 1 | shape: sequence_diagram 2 | 3 | client_one: Client Server A 4 | client_two: Client Server B 5 | reverst: Reverst Tunnel 6 | 7 | registration handshake one: { 8 | client_one -> reverst.handshake: RegisterListenerRequest 9 | reverst.handshake -> client_one: RegisterListenerResponse 10 | } 11 | 12 | registration handshake two: { 13 | client_two -> reverst.handshake_two: RegisterListenerRequest 14 | reverst.handshake_two -> client_two: RegisterListenerResponse 15 | } 16 | 17 | user: End User 18 | 19 | tunnel group: { 20 | tunnelled request one: { 21 | user -> reverst.req: Request 22 | reverst -> client_one.req: Request 23 | client_one.req -> reverst: Response 24 | reverst.req -> user: Response 25 | } 26 | 27 | tunnelled request two: { 28 | user -> reverst.req_two: Request 29 | reverst -> client_two.req_two: Request 30 | client_two.req_two -> reverst: Response 31 | reverst.req_two -> user: Response 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flipt-io/reverst/079254f5f537d00b5ba6d0540897a5e922fb142e/docs/diagram.png -------------------------------------------------------------------------------- /docs/gopher-glasses.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | Example Reverst Demonstration 2 | ----------------------------- 3 | 4 | The following walks through experimenting with the simple server example. 5 | This directory contains a number of things needed to stand up reverst and a registering client server: 6 | 7 | - The example service in [./examples/simple/main.go](./examples/simple/main.go). 8 | - Simple self-signed TLS private key and certificate. 9 | - A `reverstd` tunnel-groups YAML file which serves the `localhost` group. 10 | - A `reverst` CLI YAML file configured to connect to our tunnel. 11 | 12 | ## Building 13 | 14 | Before we start you will need to build both `reverst` and `reverstd` to get this working. 15 | 16 | ```console 17 | # from the root of this project run: 18 | 19 | go install ./cmd/... 20 | ``` 21 | 22 | ## Running 23 | 24 | ### Running `reverstd` tunnel server 25 | 26 | The following command runs the tunnel server with: 27 | 28 | - The QUIC tunnel listener on `127.0.0.1:7171` 29 | - The HTTP serving listener on `127.0.0.1:8181` 30 | - Logging with `debug` level 31 | - A TLS server-name of `localhost` 32 | - Some tunnel group definitions with a single tunnel group 33 | - The group has the name `localhost` 34 | - The group is reachable under the same host name 35 | - The group requires basic username and password authentication 36 | - The dummy TLS certificates 37 | 38 | ```console 39 | # from this simple example directory, run the following: 40 | 41 | reverstd -l debug \ 42 | -n localhost \ 43 | -g group.yml \ 44 | -k server.key \ 45 | -c server.crt 46 | ``` 47 | 48 | ### Running `reverst` tunnel client 49 | 50 | Now you can run the `reverst` CLI to proxy to any processes listening locally on port `8080`. 51 | It is setup to use the server client to register as a listener on the tunnel. 52 | 53 | ```console 54 | reverst HTTP 8080 55 | ``` 56 | 57 | ### Run something on `localhost:8080` 58 | 59 | Now you're going to want to run something you want to be tunnelled on port `8080`. 60 | 61 | For example, try running a python simple web server: 62 | 63 | ```console 64 | python3 -m http.server 8080 65 | ``` 66 | 67 | This will serve the current directory tree as a HTML page over HTTP. 68 | 69 | #### Making requests 70 | 71 | You can now curl the _tunnel_ and requests will be forward all the way through to the python server. 72 | 73 | ```curl 74 | curl localhost:8181 75 | ``` 76 | -------------------------------------------------------------------------------- /examples/simple/config.yml: -------------------------------------------------------------------------------- 1 | server-name: "localhost" 2 | cacert-path: "server.crt" 3 | insecure: true 4 | username: user 5 | password: pass 6 | -------------------------------------------------------------------------------- /examples/simple/group.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | "localhost": 3 | hosts: 4 | - "localhost" 5 | authentication: 6 | basic: 7 | username: "user" 8 | password: "pass" 9 | -------------------------------------------------------------------------------- /examples/simple/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFTDCCAzSgAwIBAgIUCyj/OZUD4FvNJeWFkPTYuefEhP8wDQYJKoZIhvcNAQEL 3 | BQAwGjEYMBYGA1UEAwwPZmxpcHQuZGV2LmxvY2FsMB4XDTI0MDQwNDE2MTM1N1oX 4 | DTM0MDQwMjE2MTM1N1owGjEYMBYGA1UEAwwPZmxpcHQuZGV2LmxvY2FsMIICIjAN 5 | BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsP2bd2+E75BLhP+KmUjkOnYOw4R7 6 | +Dh7tieGFRbKYgd6f1COoKyPzLIHWpDVMLI/ZohYc22kCLZ2bOW6CEApAV2dmbWD 7 | YxmU4EvRoRORHjzA+dNPog+o8hrNwd+I/Y0Jwui2nkPlalXYAfObANpcl6j/CADl 8 | +AO6hOOZjg/ac4pXpBEYosjikD92lHDMJu7ICr1h5WM5RZIU9zrJK2Jmgt0aXce3 9 | OjEcu2RbAZKcZao/ohTHK/mvxNbePdNsuC4AnHeCNMlKs6PQ5P7boNyXwWOkw1Pd 10 | 7lOdth4Zr6Rm76ABJzOv6tzK0i31BSwWyJOHy+1YC4izHthUEgEugzfUzzSXmXJR 11 | mfUnxRs6QafElsrb0kmUTWYQmkMHVdsHtsmDuCRZpJ/vfXDQSR4sWRS/kGWH+Pdz 12 | mzx0CqsUltcaypLYeO1uG1N5ITJ46yRCLKfE7sIuxTQSmP1d9GLPeueG2SVib5Ir 13 | LI4Pg0mk1GZhGZGOrfGjZI8Qq6Kc85BSp+GPQvpIkllgeJTOWwcCELNxtDHj6hbd 14 | +GEzZ5WIxGgCCVkOl6RPVWtPD948xsXQZKZk/3CTQABRw5LQDInvUEa6esBG/1wN 15 | 91g4ymVyWBMH+UMwtnt6mRZlqmivqxtHdusB4VAScOM/SlwhKST1qXUvOn6GXf5G 16 | 9A6Y6JaFaZWhjQUCAwEAAaOBiTCBhjAdBgNVHQ4EFgQU+8o/4o8Fvfo1dOJyoyo8 17 | 1eAIfTEwHwYDVR0jBBgwFoAU+8o/4o8Fvfo1dOJyoyo81eAIfTEwDwYDVR0TAQH/ 18 | BAUwAwEB/zAzBgNVHREELDAqgg9mbGlwdC5kZXYubG9jYWyCESouZmxpcHQuZGV2 19 | LmxvY2FshwQKAAABMA0GCSqGSIb3DQEBCwUAA4ICAQApDLq+DHWFPVjGLVWFMlbB 20 | WH7xa6oK/nchHBAv1JVp9JarTFM57kLdcRjZbhhkaRM0khMD6Gj5dtBQG8FJS+V9 21 | 9WwX9TPqoxmlDWqYUB2+0dB4LcFUckcznP7AzVEi2lPtlH+3u8FcqPU2XJ95Ugjv 22 | PTPna7G3yoNziP0nPjiXdR21jl8M7cSdzei0kL5uu1H+N5Pt57k/cQCiK9YldGNy 23 | MuyT+2HyDPYKTw5a7X3D1KM0cs/S310arLlG91mgHXrAsqC4nfXO/5K5m1FOEjz4 24 | VE/iSmNnDPJAbenHMDkIdVwby5sKh6CLuKVMz+KGytg5h0Z4OSks3nFACNWPdRap 25 | 0stAE0kfZ0JK9/CgX4LBrhONTjnehenbRoLCQt0WDYsCoN0YhWLMLFcuDZ4AwZoz 26 | C+LhDxNVooGtO60Rtj7MND/mYevfi4QixLBddN6fjdxwSuGgpsD37y5ujc9Yyt2c 27 | VHNOb/+xzHe/n9FuxKqR9dFE8xzLlBYWAMo2VokMrY8uJpzLY5d4LnmwLSP/aVNT 28 | JDP216wqJNbsEeLUKktphWSJImhmxRh8AKcI5OL1H0iuDIBuTCy57+IZ01KwfISt 29 | 2NmyxJaXURfMUNPTOnR6bxJua1YjKWPIX+h6omSe1iVP5rqiD21zuE/7xyHr+v6Y 30 | bEfGJX7BgUc+C81rErAadg== 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /examples/simple/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCw/Zt3b4TvkEuE 3 | /4qZSOQ6dg7DhHv4OHu2J4YVFspiB3p/UI6grI/MsgdakNUwsj9miFhzbaQItnZs 4 | 5boIQCkBXZ2ZtYNjGZTgS9GhE5EePMD500+iD6jyGs3B34j9jQnC6LaeQ+VqVdgB 5 | 85sA2lyXqP8IAOX4A7qE45mOD9pzilekERiiyOKQP3aUcMwm7sgKvWHlYzlFkhT3 6 | OskrYmaC3Rpdx7c6MRy7ZFsBkpxlqj+iFMcr+a/E1t4902y4LgCcd4I0yUqzo9Dk 7 | /tug3JfBY6TDU93uU522HhmvpGbvoAEnM6/q3MrSLfUFLBbIk4fL7VgLiLMe2FQS 8 | AS6DN9TPNJeZclGZ9SfFGzpBp8SWytvSSZRNZhCaQwdV2we2yYO4JFmkn+99cNBJ 9 | HixZFL+QZYf493ObPHQKqxSW1xrKkth47W4bU3khMnjrJEIsp8Tuwi7FNBKY/V30 10 | Ys9654bZJWJvkissjg+DSaTUZmEZkY6t8aNkjxCropzzkFKn4Y9C+kiSWWB4lM5b 11 | BwIQs3G0MePqFt34YTNnlYjEaAIJWQ6XpE9Va08P3jzGxdBkpmT/cJNAAFHDktAM 12 | ie9QRrp6wEb/XA33WDjKZXJYEwf5QzC2e3qZFmWqaK+rG0d26wHhUBJw4z9KXCEp 13 | JPWpdS86foZd/kb0DpjoloVplaGNBQIDAQABAoICAFM8YbHws18KyEla+G6QNi08 14 | OkM++iJJRfzrZ4ZhenpRtA1N8FWl8da9XvH7rRdCrMhmlNpsFLm4PceG3k9lBSPM 15 | SALyt0CS1k4JuiN7lULYJZUYCJpZrQV3D47+tmkvvAh68u9JVQoGJxj73Miw6Ny2 16 | MIIuEfjqn/xBIYUK9Zxi0kapd6GOweY+wmCbD5e7sj5CxFhhqpyKT+vZLKSHmVgv 17 | 7WdvNj9au31xe88oiP7S6ywpg2Wt/jifKFOBK98OW2E6XvS+ObBz+RjItrdXJtNf 18 | LixiM7wSfNgaHdBZi8Njx0cNas0UWxovXMy66FgZ8cI487Aw8LQv3pG9FofFaQHk 19 | FyfGOTcVca1yIAeyVv6Zw2dB0V+1g09wICuR8e+OtJmWgcrd5WU8E9w6ozdA6X2A 20 | kiY1xlP0ohJ/9KaFogovDL+oTMFvAh0cIU3LAh5lXIY4uWcpDXhX+WxOvOe1kBpx 21 | sjlfliZoDFAizQRoNmZ51/1NfNGUjzMPINFc3zM3n/X75RpQ4zI7CHNiO36qF4+T 22 | kXC3OXu3dyYXcUWD7f1DvJ5Sd8XYBEk5snqPkgiDNCSYsnVZ7mWFPstS3DONpofg 23 | 68X5I/PvZd9KwMn9RILWEdXyneqQAJUiBDAYQNp0ONuW7+FzXHh+DpIeT1DBNFWW 24 | CPE45f2jIv6wV/2MZkk9AoIBAQDsRYGpm+orenkcGab7T17tbNMCoXY7ECTAXdHD 25 | X/StsRDnxmI6VdNMRHDl3e4exu3B11xJynFqJ51+nP3X+z32TuYlReY20DzG9+G1 26 | thBnA5kP8vriMdP3uTS6nKBdy5xpTJg4uJpIS0URrnShOVEqxuoe9dXFikapuCig 27 | Es4fm0qmIaAeWOmhjMN0nJzu2g6VFtXzuQGWp6xF8u4rqwwNy5iWA4EFLykEMbju 28 | 4etu/47GRLwd53NVmyTTwbrT5c1tzcMpqHqscbriIMg5+dubmO4mHcWI7Z/dkzUb 29 | lE8wOEjm4EoxOOgDYYI/HkAPQQh0WuJkYX+mWyt0XAJ9haNbAoIBAQC/xOzBc0kn 30 | MCrh0dszOcfimZLddPMzpfC8JsPbV09p7FECyXx95sj0RVcsZEv6cSb8sz0RfqfG 31 | POsCft6oJ4yrOzd9bKBcfsvdhHWl/8KV3iKcsvuUPwgKJ9k/HgsZdd2B/uheuc/t 32 | L2XFXObmIcWXjsjNeBVAFCXxgrxsEApXTVq7PMwwZuCa11rEHf8QO8S3reMeLx1O 33 | gThNp8DYlIs0Z4z2rE9UcKSC+1yhT6t/xP6Ik1Jm9wWlSW7R/7boDZGkij0Ld3or 34 | AKg7DBWKitz4h4+MuTAZWXcwheNtUrukiHYIs25kGTTzKlmFFtpDsTkBYtC/Xw8M 35 | NqWbo9e8vF8fAoIBACG9n1GoWV2SJN91blovMIA5bTzP8AFpQcZS35TGDDwNsDtZ 36 | IkfwX7dhgrng4pN0lxl6dsx245FkTOJ66ulAEM4u4EDsZX7oZ/WGCq6XbGo5ok8b 37 | xOYudXXH9ha8LU6XuCN1c7uN6UJvtVJcFn27oLK6K072jmuiXor4KJ0LcnIjDBsD 38 | bElJKzwNzQ/HGcYmT7TMyQsAHZD1Ku3mTkrgJ12E+Kpbn74syO+3ZhB/JM76IdqA 39 | kFi458iO2kh/Pv3psFeA7eDyFWqw5x5Jzi4xV0I2KbQ5xi7SuGS9E/V3TYO2Vfra 40 | ht3WoZNyBlabLEzBXcK4A+KP5dnM5Ty9swFX50cCggEAQkGUl0XwqWR//GOYO3uk 41 | LwtmbbNBb7/OjANMuhTRcERgvPjGVW2OUq8+biz8/ztm//ohiFDdQKW9gLA615hK 42 | hsK/6krEoRCB+TgRudfC8Cq+hKtmNpMMgQ3Bhognx5UsggSDXm8OguFUZTsy36Id 43 | db60bCEoHmqXrXjBBwkHuaAVUAmydqqkwXrUdlvyyzYLQncGuGAI3R3SSSpDe9De 44 | w61fS76+7eq5PDQCjg1LsKpsUt2ro72HKtNTQaT7GfPONS455yNRh1gDx9rl+mb9 45 | 77ROiY1WK/v4LUClEhl6kV9AgMMVyenXnIguQ8hggvDRRERS4TvDpDGhSEP0ftYK 46 | ewKCAQEAprqUOvZjBLNUDC1WurUs23+Ly5HdT28GyLSdxT7KpFlYI+eNPpy7xfet 47 | LaQJ0ovTn6+kiQivpPlSLlR2AsCMjsuY1QkQnaQfTUyr8JXgkUNz/4hy4O4e2Ngp 48 | ZvHOzH5WAn6pZWy4vTSGGrvy25gNBiFse94VpP6BRRSkIrGIGcEPWr47rhI6cwgW 49 | +X17fL6aLVuQ17HCbfjWyDqkG5uFGQyW6EMa6MmOxDibItwAQHx4qpGnZyxCdcgP 50 | dkDV9jHwaBmFeZEjBfOtGLUqhFVTdlrv98E81qWebC46XClrzsVw5vA2KBjB23gL 51 | CTRPMwK+sXwS2V9miU9UFeIoKAJ7MA== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.flipt.io/reverst 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.22.2 6 | 7 | require ( 8 | github.com/fsnotify/fsnotify v1.7.0 9 | github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 10 | github.com/prometheus/client_golang v1.19.1 11 | github.com/quic-go/quic-go v0.48.2 12 | github.com/stretchr/testify v1.9.0 13 | github.com/vmihailenco/msgpack/v5 v5.4.1 14 | go.opentelemetry.io/otel v1.25.0 15 | go.opentelemetry.io/otel/exporters/prometheus v0.47.0 16 | go.opentelemetry.io/otel/metric v1.25.0 17 | go.opentelemetry.io/otel/sdk/metric v1.25.0 18 | golang.org/x/sync v0.10.0 19 | gopkg.in/yaml.v2 v2.4.0 20 | gopkg.in/yaml.v3 v3.0.1 21 | k8s.io/api v0.30.0 22 | k8s.io/apimachinery v0.30.0 23 | k8s.io/client-go v0.30.0 24 | ) 25 | 26 | require ( 27 | github.com/beorn7/perks v1.0.1 // indirect 28 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 31 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 32 | github.com/go-logr/logr v1.4.1 // indirect 33 | github.com/go-logr/stdr v1.2.2 // indirect 34 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 35 | github.com/go-openapi/jsonreference v0.20.2 // indirect 36 | github.com/go-openapi/swag v0.22.3 // indirect 37 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 38 | github.com/gogo/protobuf v1.3.2 // indirect 39 | github.com/golang/protobuf v1.5.4 // indirect 40 | github.com/google/gnostic-models v0.6.8 // indirect 41 | github.com/google/go-cmp v0.6.0 // indirect 42 | github.com/google/gofuzz v1.2.0 // indirect 43 | github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 // indirect 44 | github.com/google/uuid v1.3.0 // indirect 45 | github.com/imdario/mergo v0.3.6 // indirect 46 | github.com/josharian/intern v1.0.0 // indirect 47 | github.com/json-iterator/go v1.1.12 // indirect 48 | github.com/mailru/easyjson v0.7.7 // indirect 49 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 50 | github.com/modern-go/reflect2 v1.0.2 // indirect 51 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 52 | github.com/onsi/ginkgo/v2 v2.17.3 // indirect 53 | github.com/pkg/errors v0.9.1 // indirect 54 | github.com/pmezard/go-difflib v1.0.0 // indirect 55 | github.com/prometheus/client_model v0.6.1 // indirect 56 | github.com/prometheus/common v0.48.0 // indirect 57 | github.com/prometheus/procfs v0.12.0 // indirect 58 | github.com/quic-go/qpack v0.5.1 // indirect 59 | github.com/spf13/pflag v1.0.5 // indirect 60 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 61 | go.opentelemetry.io/otel/sdk v1.25.0 // indirect 62 | go.opentelemetry.io/otel/trace v1.25.0 // indirect 63 | go.uber.org/mock v0.4.0 // indirect 64 | golang.org/x/crypto v0.31.0 // indirect 65 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 66 | golang.org/x/mod v0.17.0 // indirect 67 | golang.org/x/net v0.28.0 // indirect 68 | golang.org/x/oauth2 v0.16.0 // indirect 69 | golang.org/x/sys v0.28.0 // indirect 70 | golang.org/x/term v0.27.0 // indirect 71 | golang.org/x/text v0.21.0 // indirect 72 | golang.org/x/time v0.5.0 // indirect 73 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 74 | google.golang.org/appengine v1.6.7 // indirect 75 | google.golang.org/protobuf v1.33.0 // indirect 76 | gopkg.in/inf.v0 v0.9.1 // indirect 77 | k8s.io/klog/v2 v2.120.1 // indirect 78 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 79 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect 80 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 81 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 82 | sigs.k8s.io/yaml v1.3.0 // indirect 83 | ) 84 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 4 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 10 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 11 | github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= 12 | github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 13 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 14 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 15 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 16 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 17 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 18 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 19 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 20 | github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= 21 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 22 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 23 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 24 | github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= 25 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 26 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 27 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 28 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 29 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 30 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 31 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 32 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 33 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 34 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 35 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 36 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 37 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 38 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 39 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 40 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 41 | github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 h1:velgFPYr1X9TDwLIfkV7fWqsFlf7TeP11M/7kPd/dVI= 42 | github.com/google/pprof v0.0.0-20240509144519-723abb6459b7/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 43 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 44 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 45 | github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= 46 | github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 47 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 48 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 49 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 50 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 51 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 52 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 53 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 54 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 55 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 56 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 58 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 59 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 60 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 61 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 62 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 63 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 64 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 65 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 66 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 67 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 68 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 69 | github.com/onsi/ginkgo/v2 v2.17.3 h1:oJcvKpIb7/8uLpDDtnQuf18xVnwKp8DTD7DQ6gTd/MU= 70 | github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= 71 | github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= 72 | github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= 73 | github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= 74 | github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 75 | github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 h1:aiqS8aBlF9PsAKeMddMSfbwp3smONCn3UO8QfUg0Z7Y= 76 | github.com/peterbourgon/ff/v4 v4.0.0-alpha.4/go.mod h1:H/13DK46DKXy7EaIxPhk2Y0EC8aubKm35nBjBe8AAGc= 77 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 78 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 79 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 80 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 81 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 82 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 83 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 84 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 85 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 86 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 87 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 88 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 89 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 90 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 91 | github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE= 92 | github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= 93 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 94 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 95 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 96 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 97 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 98 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 99 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 100 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 101 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 102 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 103 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 104 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 105 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 106 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 107 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 108 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 109 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 110 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 111 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 112 | go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k= 113 | go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg= 114 | go.opentelemetry.io/otel/exporters/prometheus v0.47.0 h1:OL6yk1Z/pEGdDnrBbxSsH+t4FY1zXfBRGd7bjwhlMLU= 115 | go.opentelemetry.io/otel/exporters/prometheus v0.47.0/go.mod h1:xF3N4OSICZDVbbYZydz9MHFro1RjmkPUKEvar2utG+Q= 116 | go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA= 117 | go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s= 118 | go.opentelemetry.io/otel/sdk v1.25.0 h1:PDryEJPC8YJZQSyLY5eqLeafHtG+X7FWnf3aXMtxbqo= 119 | go.opentelemetry.io/otel/sdk v1.25.0/go.mod h1:oFgzCM2zdsxKzz6zwpTZYLLQsFwc+K0daArPdIhuxkw= 120 | go.opentelemetry.io/otel/sdk/metric v1.25.0 h1:7CiHOy08LbrxMAp4vWpbiPcklunUshVpAvGBrdDRlGw= 121 | go.opentelemetry.io/otel/sdk/metric v1.25.0/go.mod h1:LzwoKptdbBBdYfvtGCzGwk6GWMA3aUzBOwtQpR6Nz7o= 122 | go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM= 123 | go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I= 124 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 125 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 126 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 127 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 128 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 129 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 130 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 131 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 132 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 133 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 134 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 135 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 136 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 137 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 138 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 139 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 140 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 141 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 142 | golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 143 | golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 144 | golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= 145 | golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= 146 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 147 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 148 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 149 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 150 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 151 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 152 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 154 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 155 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 156 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 157 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 158 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 159 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 160 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 161 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 162 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 163 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 164 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 165 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 166 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 167 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 168 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 169 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 170 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 171 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 172 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 173 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 174 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 175 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 176 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 177 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 178 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 179 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 180 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 181 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 182 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 183 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 184 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 185 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 186 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 187 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 188 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 189 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 190 | k8s.io/api v0.30.0 h1:siWhRq7cNjy2iHssOB9SCGNCl2spiF1dO3dABqZ8niA= 191 | k8s.io/api v0.30.0/go.mod h1:OPlaYhoHs8EQ1ql0R/TsUgaRPhpKNxIMrKQfWUp8QSE= 192 | k8s.io/apimachinery v0.30.0 h1:qxVPsyDM5XS96NIh9Oj6LavoVFYff/Pon9cZeDIkHHA= 193 | k8s.io/apimachinery v0.30.0/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= 194 | k8s.io/client-go v0.30.0 h1:sB1AGGlhY/o7KCyCEQ0bPWzYDL0pwOZO4vAtTSh/gJQ= 195 | k8s.io/client-go v0.30.0/go.mod h1:g7li5O5256qe6TYdAMyX/otJqMhIiGgTapdLchhmOaY= 196 | k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= 197 | k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 198 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 199 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 200 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= 201 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 202 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 203 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 204 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 205 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 206 | sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= 207 | sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= 208 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.22.0 2 | 3 | toolchain go1.22.2 4 | 5 | use ( 6 | . 7 | ./dagger 8 | ) 9 | -------------------------------------------------------------------------------- /internal/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/sha256" 5 | "crypto/subtle" 6 | "encoding/base64" 7 | "encoding/hex" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "log/slog" 12 | "net/http" 13 | "strings" 14 | 15 | "go.flipt.io/reverst/pkg/protocol" 16 | ) 17 | 18 | // ErrUnauthorized is returned when the attempted request is not authorized 19 | var ErrUnauthorized = errors.New("unauthorized") 20 | 21 | const unauthorizedMsg = "listener request unauthorized" 22 | 23 | type Authenticator map[string]Handler 24 | 25 | func (a Authenticator) Authenticate(r *protocol.RegisterListenerRequest) error { 26 | log := slog.With("tunnel_group", r.TunnelGroup) 27 | 28 | if len(a) == 0 { 29 | log.Debug("No handlers configured skipping authentication") 30 | return nil 31 | } 32 | 33 | auth, ok := r.Metadata["Authorization"] 34 | if !ok { 35 | log.Info(unauthorizedMsg, "reason", "missing authorization metadata") 36 | return ErrUnauthorized 37 | } 38 | 39 | scheme, payload, ok := strings.Cut(strings.TrimSpace(auth), " ") 40 | if !ok { 41 | log.Info(unauthorizedMsg, "reason", "malformed authorization payload") 42 | return ErrUnauthorized 43 | } 44 | 45 | log = log.With("scheme", scheme) 46 | 47 | handler, ok := a[scheme] 48 | if !ok { 49 | log.Info(unauthorizedMsg, "reason", "unsupported scheme") 50 | return ErrUnauthorized 51 | } 52 | 53 | if err := handler.Authenticate(scheme, payload); err != nil { 54 | log.Info(unauthorizedMsg, "reason", err) 55 | return ErrUnauthorized 56 | } 57 | 58 | return nil 59 | } 60 | 61 | type Handler interface { 62 | Authenticate(scheme, payload string) error 63 | } 64 | 65 | type AuthenticationHandlerFunc func(scheme, payload string) error 66 | 67 | func (r AuthenticationHandlerFunc) Authenticate(scheme, payload string) error { 68 | return r(scheme, payload) 69 | } 70 | 71 | // HandleBasic performs basic authentication for register listener request metadata 72 | func HandleBasic(username, password string) Handler { 73 | expectedUsername := safeComparator([]byte(username)) 74 | expectedPassword := safeComparator([]byte(password)) 75 | 76 | return AuthenticationHandlerFunc(func(scheme, cred string) error { 77 | if !strings.EqualFold(scheme, "Basic") { 78 | return fmt.Errorf("basic: unexpected scheme %q", scheme) 79 | } 80 | 81 | dec, err := base64.StdEncoding.DecodeString(cred) 82 | if err != nil { 83 | return fmt.Errorf("basic: decoding credentials: %w", err) 84 | } 85 | 86 | username, password, ok := strings.Cut(string(dec), ":") 87 | if !ok { 88 | return errors.New("basic: unexpected payload format") 89 | } 90 | 91 | if !(expectedUsername(username) && expectedPassword(password)) { 92 | return errors.New("basic: username or password unexpected") 93 | } 94 | 95 | return nil 96 | }) 97 | } 98 | 99 | // BearerSource is any type that returns a credential which can be used 100 | // to authenticate a tunnel registration 101 | type BearerSource interface { 102 | // GetCredential returns a bearer credential 103 | // HandleBearerSource expects all tokens to have been hashed with SHA256 104 | GetCredential() ([]byte, error) 105 | } 106 | 107 | // BearerSourceFunc is a function which implements BearerSource 108 | type BearerSourceFunc func() ([]byte, error) 109 | 110 | // GetCredential delegates to the underlying BearerSourceFunc 111 | func (fn BearerSourceFunc) GetCredential() ([]byte, error) { 112 | return fn() 113 | } 114 | 115 | // StaticBearerSource is a BearerSource that returns the provided 116 | // expected token after hashing it with SHA256 117 | func StaticBearerSource(expected []byte) BearerSource { 118 | sum := sha256.Sum256(expected) 119 | return BearerSourceFunc(func() ([]byte, error) { 120 | return sum[:], nil 121 | }) 122 | } 123 | 124 | // HashedStaticBearerSource is a BearerSource that returns the provided 125 | // expected token decoded from hexidecimal (assumes it was pre-hashed with SHA256) 126 | func HashedStaticBearerSource(expected []byte) (BearerSource, error) { 127 | dst := make([]byte, hex.DecodedLen(len(expected))) 128 | if _, err := hex.Decode(dst, expected); err != nil { 129 | return nil, err 130 | } 131 | 132 | return BearerSourceFunc(func() ([]byte, error) { 133 | return dst, nil 134 | }), nil 135 | } 136 | 137 | // HandleBearerSource returns an authentication handler which delegates 138 | // to the provided BearerSource to obtain credentials which it then performs 139 | // a safe comparison on with the SHA256 sum of the presented tokens 140 | func HandleBearerSource(src BearerSource) Handler { 141 | return AuthenticationHandlerFunc(func(scheme, token string) error { 142 | if !strings.EqualFold(scheme, "Bearer") { 143 | return fmt.Errorf("bearer: unexpected scheme %q", scheme) 144 | } 145 | 146 | expected, err := src.GetCredential() 147 | if err != nil { 148 | return err 149 | } 150 | 151 | sum := sha256.Sum256([]byte(token)) 152 | if subtle.ConstantTimeCompare(expected, sum[:]) != 1 { 153 | return errors.New("bearer: token was not expected value") 154 | } 155 | 156 | return nil 157 | }) 158 | } 159 | 160 | func HandleExternalAuthorizer(addr string) Handler { 161 | client := &http.Client{} 162 | 163 | return AuthenticationHandlerFunc(func(scheme, payload string) error { 164 | req, err := http.NewRequest("GET", addr, nil) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | req.Header.Set("Authorization", fmt.Sprintf("%s %s", scheme, payload)) 170 | 171 | resp, err := client.Do(req) 172 | if err != nil { 173 | return err 174 | } 175 | defer resp.Body.Close() 176 | 177 | if resp.StatusCode == http.StatusOK { 178 | return nil 179 | } 180 | 181 | body, _ := io.ReadAll(resp.Body) 182 | 183 | return fmt.Errorf("external: unexpected response (status %d) %q", resp.StatusCode, string(body)) 184 | }) 185 | } 186 | 187 | func safeComparator(expected []byte) func(string) bool { 188 | expectedSum := sha256.Sum256(expected) 189 | return func(presented string) bool { 190 | presentedSum := sha256.Sum256([]byte(presented)) 191 | return subtle.ConstantTimeCompare(expectedSum[:], presentedSum[:]) == 1 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /internal/auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | "go.flipt.io/reverst/pkg/protocol" 13 | ) 14 | 15 | var ( 16 | basic = HandleBasic("morty", "gazorpazorp") 17 | bearer = HandleBearerSource(StaticBearerSource([]byte("plumbus"))) 18 | hashedToken, _ = HashedStaticBearerSource([]byte("34831eccb70007e3ed06bb8ba0e2c80e661c440d09fb6513c96cd1fdeb5c57cc")) 19 | bearerHashed = HandleBearerSource(hashedToken) 20 | external Handler 21 | ) 22 | 23 | func mustHexDecodeString(v string) []byte { 24 | b, err := hex.DecodeString(v) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | return b 30 | } 31 | 32 | func TestMain(m *testing.M) { 33 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | if r.URL.Path != "/ext/auth" { 35 | w.WriteHeader(http.StatusNotFound) 36 | w.Write([]byte("not found")) 37 | return 38 | } 39 | 40 | if auth := r.Header.Get("Authorization"); auth != "Bearer plumbus" { 41 | w.WriteHeader(http.StatusUnauthorized) 42 | w.Write([]byte("unauthorized")) 43 | } 44 | })) 45 | defer srv.Close() 46 | 47 | external = HandleExternalAuthorizer(fmt.Sprintf("http://%s/ext/auth", srv.Listener.Addr().String())) 48 | 49 | os.Exit(m.Run()) 50 | } 51 | 52 | func Test_Handlers(t *testing.T) { 53 | for _, test := range []struct { 54 | name string 55 | handler Handler 56 | request protocol.RegisterListenerRequest 57 | expectedErr error 58 | }{ 59 | { 60 | name: "basic: matches", 61 | handler: basic, 62 | request: protocol.RegisterListenerRequest{ 63 | TunnelGroup: "sanchez", 64 | Metadata: map[string]string{ 65 | "Authorization": "Basic bW9ydHk6Z2F6b3JwYXpvcnA=", 66 | }, 67 | }, 68 | }, 69 | { 70 | name: "basic: missing metadata key", 71 | handler: basic, 72 | request: protocol.RegisterListenerRequest{ 73 | TunnelGroup: "sanchez", 74 | Metadata: map[string]string{ 75 | "WrongKey": "Basic bW9ydHk6Z2F6b3JwYXpvcnA=", 76 | }, 77 | }, 78 | expectedErr: ErrUnauthorized, 79 | }, 80 | { 81 | name: "basic: unexpected scheme", 82 | handler: basic, 83 | request: protocol.RegisterListenerRequest{ 84 | TunnelGroup: "sanchez", 85 | Metadata: map[string]string{ 86 | "Authorization": "Unknown bW9ydHk6Z2F6b3JwYXpvcnA=", 87 | }, 88 | }, 89 | expectedErr: ErrUnauthorized, 90 | }, 91 | { 92 | name: "basic: unexpected encoding", 93 | handler: basic, 94 | request: protocol.RegisterListenerRequest{ 95 | TunnelGroup: "sanchez", 96 | Metadata: map[string]string{ 97 | "Authorization": "Basic th*s i% n@t b@$£64", 98 | }, 99 | }, 100 | expectedErr: ErrUnauthorized, 101 | }, 102 | { 103 | name: "basic: unexpected form (missing colon)", 104 | handler: basic, 105 | request: protocol.RegisterListenerRequest{ 106 | TunnelGroup: "sanchez", 107 | Metadata: map[string]string{ 108 | "Authorization": "Basic bW9ydHlnYXpvcnBhem9ycA==", 109 | }, 110 | }, 111 | expectedErr: ErrUnauthorized, 112 | }, 113 | { 114 | name: "basic: unknown username", 115 | handler: basic, 116 | request: protocol.RegisterListenerRequest{ 117 | TunnelGroup: "sanchez", 118 | Metadata: map[string]string{ 119 | "Authorization": "Basic ZXZpbG1vcnR5Om11bHRpdmVyc2U=", 120 | }, 121 | }, 122 | expectedErr: ErrUnauthorized, 123 | }, 124 | { 125 | name: "basic: unknown password", 126 | handler: basic, 127 | request: protocol.RegisterListenerRequest{ 128 | TunnelGroup: "sanchez", 129 | Metadata: map[string]string{ 130 | "Authorization": "Basic bW9ydHk6bXVsdGl2ZXJzZQ==", 131 | }, 132 | }, 133 | expectedErr: ErrUnauthorized, 134 | }, 135 | { 136 | name: "bearer: matches", 137 | handler: bearer, 138 | request: protocol.RegisterListenerRequest{ 139 | TunnelGroup: "sanchez", 140 | Metadata: map[string]string{ 141 | "Authorization": "Bearer plumbus", 142 | }, 143 | }, 144 | }, 145 | { 146 | name: "bearer: missing metadata key", 147 | handler: bearer, 148 | request: protocol.RegisterListenerRequest{ 149 | TunnelGroup: "sanchez", 150 | Metadata: map[string]string{ 151 | "WrongKey": "Basic bW9ydHk6Z2F6b3JwYXpvcnA=", 152 | }, 153 | }, 154 | expectedErr: ErrUnauthorized, 155 | }, 156 | { 157 | name: "bearer: unexpected scheme", 158 | handler: bearer, 159 | request: protocol.RegisterListenerRequest{ 160 | TunnelGroup: "sanchez", 161 | Metadata: map[string]string{ 162 | "Authorization": "Unknown plumbus", 163 | }, 164 | }, 165 | expectedErr: ErrUnauthorized, 166 | }, 167 | { 168 | name: "bearer: unknown token", 169 | handler: bearer, 170 | request: protocol.RegisterListenerRequest{ 171 | TunnelGroup: "sanchez", 172 | Metadata: map[string]string{ 173 | "Authorization": "Bearer wubalubadubdub", 174 | }, 175 | }, 176 | expectedErr: ErrUnauthorized, 177 | }, 178 | { 179 | name: "bearerHashed: matches", 180 | handler: bearerHashed, 181 | request: protocol.RegisterListenerRequest{ 182 | TunnelGroup: "sanchez", 183 | Metadata: map[string]string{ 184 | "Authorization": "Bearer plumbus", 185 | }, 186 | }, 187 | }, 188 | { 189 | name: "bearerHashed: missing metadata key", 190 | handler: bearerHashed, 191 | request: protocol.RegisterListenerRequest{ 192 | TunnelGroup: "sanchez", 193 | Metadata: map[string]string{ 194 | "WrongKey": "Basic bW9ydHk6Z2F6b3JwYXpvcnA=", 195 | }, 196 | }, 197 | expectedErr: ErrUnauthorized, 198 | }, 199 | { 200 | name: "bearerHashed: unexpected scheme", 201 | handler: bearerHashed, 202 | request: protocol.RegisterListenerRequest{ 203 | TunnelGroup: "sanchez", 204 | Metadata: map[string]string{ 205 | "Authorization": "Unknown plumbus", 206 | }, 207 | }, 208 | expectedErr: ErrUnauthorized, 209 | }, 210 | { 211 | name: "bearerHashed: unknown token", 212 | handler: bearerHashed, 213 | request: protocol.RegisterListenerRequest{ 214 | TunnelGroup: "sanchez", 215 | Metadata: map[string]string{ 216 | "Authorization": "Bearer wubalubadubdub", 217 | }, 218 | }, 219 | expectedErr: ErrUnauthorized, 220 | }, 221 | { 222 | name: "external: matches", 223 | handler: external, 224 | request: protocol.RegisterListenerRequest{ 225 | TunnelGroup: "sanchez", 226 | Metadata: map[string]string{ 227 | "Authorization": "Bearer plumbus", 228 | }, 229 | }, 230 | }, 231 | { 232 | name: "external: missing metadata key", 233 | handler: external, 234 | request: protocol.RegisterListenerRequest{ 235 | TunnelGroup: "sanchez", 236 | Metadata: map[string]string{ 237 | "WrongKey": "Bearer plumbus", 238 | }, 239 | }, 240 | expectedErr: ErrUnauthorized, 241 | }, 242 | { 243 | name: "external: unexpected scheme", 244 | handler: external, 245 | request: protocol.RegisterListenerRequest{ 246 | TunnelGroup: "sanchez", 247 | Metadata: map[string]string{ 248 | "Authorization": "Unknown plumbus", 249 | }, 250 | }, 251 | expectedErr: ErrUnauthorized, 252 | }, 253 | { 254 | name: "external: unknown token", 255 | handler: external, 256 | request: protocol.RegisterListenerRequest{ 257 | TunnelGroup: "sanchez", 258 | Metadata: map[string]string{ 259 | "Authorization": "Bearer wubalubadubdub", 260 | }, 261 | }, 262 | expectedErr: ErrUnauthorized, 263 | }, 264 | } { 265 | t.Run(test.name, func(t *testing.T) { 266 | handler := Authenticator{ 267 | "Basic": basic, 268 | "Bearer": bearer, 269 | "JWT": external, 270 | } 271 | 272 | err := handler.Authenticate(&test.request) 273 | if test.expectedErr == nil { 274 | require.NoError(t, err) 275 | return 276 | } 277 | 278 | require.ErrorIs(t, err, test.expectedErr) 279 | }) 280 | } 281 | } 282 | 283 | func FuzzBasic(f *testing.F) { 284 | f.Add("someunexpectedpayload") 285 | f.Add("Basic th*s i% n@t b@$£64") 286 | f.Add("Basic c29tZWludmFsaWQ6Y29tYmluYXRpb24=") 287 | f.Fuzz(func(t *testing.T, a string) { 288 | require.ErrorIs(t, Authenticator{"Bearer": basic}.Authenticate( 289 | &protocol.RegisterListenerRequest{ 290 | TunnelGroup: "sanchez", 291 | Metadata: map[string]string{ 292 | "Authorization": a, 293 | }, 294 | }, 295 | ), ErrUnauthorized) 296 | }) 297 | } 298 | 299 | func FuzzBearer(f *testing.F) { 300 | f.Add("someunexpectedpayload") 301 | f.Add("Bearer th*s i% n@t b@$£64") 302 | f.Add("Bearer c29tZWludmFsaWQ6Y29tYmluYXRpb24=") 303 | f.Fuzz(func(t *testing.T, a string) { 304 | require.ErrorIs(t, Authenticator{"Bearer": bearer}.Authenticate( 305 | &protocol.RegisterListenerRequest{ 306 | TunnelGroup: "sanchez", 307 | Metadata: map[string]string{ 308 | "Authorization": a, 309 | }, 310 | }, 311 | ), ErrUnauthorized) 312 | }) 313 | } 314 | 315 | func FuzzBearerHashed(f *testing.F) { 316 | f.Add("someunexpectedpayload") 317 | f.Add("Bearer th*s i% n@t b@$£64") 318 | f.Add("Bearer c29tZWludmFsaWQ6Y29tYmluYXRpb24=") 319 | f.Fuzz(func(t *testing.T, a string) { 320 | require.ErrorIs(t, Authenticator{"Bearer": bearerHashed}.Authenticate( 321 | &protocol.RegisterListenerRequest{ 322 | TunnelGroup: "sanchez", 323 | Metadata: map[string]string{ 324 | "Authorization": a, 325 | }, 326 | }, 327 | ), ErrUnauthorized) 328 | }) 329 | } 330 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "reflect" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "go.flipt.io/reverst/internal/auth" 17 | "go.flipt.io/reverst/pkg/protocol" 18 | ) 19 | 20 | var ( 21 | kSource *k8sSource 22 | kSourceErr error 23 | once sync.Once 24 | ) 25 | 26 | func getK8sSource(ctx context.Context) (*k8sSource, error) { 27 | once.Do(func() { 28 | kSource, kSourceErr = newK8sSource(ctx) 29 | }) 30 | 31 | return kSource, kSourceErr 32 | } 33 | 34 | type Config struct { 35 | Level Level `ff:" short=l | long=log | default=info | usage: 'debug, info, warn or error' "` 36 | TunnelAddress string `ff:" short=a | long=tunnel-address | default='127.0.0.1:7171' | usage: address for accepting tunnelling quic connections "` 37 | HTTPAddress string `ff:" short=s | long=http-address | default='0.0.0.0:8181' | usage: address for serving HTTP requests "` 38 | ServerName string `ff:" short=n | long=server-name | usage: server name used to identify tunnel via TLS (required) "` 39 | PrivateKeyPath string `ff:" short=k | long=private-key-path | usage: path to TLS private key PEM file (required) "` 40 | CertificatePath string `ff:" short=c | long=certificate-path | usage: path to TLS certificate PEM file (required) "` 41 | TunnelGroups string `ff:" short=g | long=tunnel-groups | default='groups.yml' | usage: path to file or k8s configmap identifier "` 42 | WatchTunnelGroups bool `ff:" short=w | long=watch-groups | default=false | usage: watch tunnel groups sources for updates "` 43 | 44 | // ManagementAddress is where reverst hosts introspective APIs for telemetry and debugging etc. 45 | ManagementAddress string `ff:" long=management-address | usage: HTTP address for management API "` 46 | 47 | MaxIdleTimeout time.Duration `ff:" long=max-idle-timeout | default=1m | usage: maximum time a connection can be idle "` 48 | KeepAlivePeriod time.Duration `ff:" long=keep-alive-period | default=30s | usage: period between keep-alive events "` 49 | } 50 | 51 | func (c Config) Validate() error { 52 | if c.ServerName == "" { 53 | return errors.New("server-name must be non-empty string") 54 | } 55 | 56 | if c.PrivateKeyPath == "" { 57 | return errors.New("private-key-path must be non-empty string") 58 | } 59 | 60 | if c.CertificatePath == "" { 61 | return errors.New("certificate-path must be non-empty string") 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (c *Config) SubscribeTunnelGroups(ctx context.Context, ch chan<- *TunnelGroups) error { 68 | u, err := url.Parse(c.TunnelGroups) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | switch u.Scheme { 74 | case "", "file": 75 | return watchFSNotify(ctx, ch, filepath.Join(u.Host, u.Path), c.WatchTunnelGroups) 76 | case "k8s": 77 | if u.Host == "configmap" { 78 | ns, name, key, err := k8sObjectNamespaceNameKey(u.Path) 79 | if err != nil { 80 | return fmt.Errorf("unexpected k8s configmap path: %w", err) 81 | } 82 | 83 | source, err := getK8sSource(ctx) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | return source.watchConfigMap(ctx, ch, ns, name, key) 89 | } 90 | 91 | return fmt.Errorf("unsupported k8s resource: %q (expected [configmap])", u.Host) 92 | } 93 | 94 | return fmt.Errorf("unexpected tunnel group scheme: %q", c.TunnelGroups) 95 | } 96 | 97 | // TunnelGroups is a configuration file format for defining the tunnel 98 | // groups served by an instance of then reverst tunnel server. 99 | type TunnelGroups struct { 100 | Groups map[string]TunnelGroup `json:"groups,omitempty" yaml:"groups,omitempty"` 101 | } 102 | 103 | func (g TunnelGroups) Validate(ctx context.Context) error { 104 | for _, g := range g.Groups { 105 | if err := g.Validate(ctx); err != nil { 106 | return err 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func (g TunnelGroups) AuthenticationHandler() protocol.AuthenticationHandler { 114 | handlers := map[string]protocol.AuthenticationHandler{} 115 | for name, group := range g.Groups { 116 | handler := auth.Authenticator{} 117 | 118 | if basic := group.Authentication.Basic; basic != nil { 119 | scheme := basic.Scheme 120 | if scheme == "" { 121 | scheme = "Basic" 122 | } 123 | 124 | handler[scheme] = auth.HandleBasic(basic.Username, basic.Password) 125 | } 126 | 127 | if bearer := group.Authentication.Bearer; bearer != nil { 128 | scheme := bearer.Scheme 129 | if scheme == "" { 130 | scheme = "Bearer" 131 | } 132 | 133 | handler[scheme] = auth.HandleBearerSource(bearer.source) 134 | } 135 | 136 | if external := group.Authentication.External; external != nil { 137 | scheme := external.Scheme 138 | if scheme == "" { 139 | scheme = "Bearer" 140 | } 141 | 142 | handler[scheme] = auth.HandleExternalAuthorizer(external.Endpoint) 143 | } 144 | 145 | handlers[name] = handler 146 | } 147 | 148 | return protocol.AuthenticationHandlerFunc(func(rlr *protocol.RegisterListenerRequest) error { 149 | handler, ok := handlers[rlr.TunnelGroup] 150 | if !ok { 151 | return fmt.Errorf("unknown tunnel group: %q", rlr.TunnelGroup) 152 | } 153 | 154 | return handler.Authenticate(rlr) 155 | }) 156 | } 157 | 158 | // TunnelGroup is an instance of a tunnel group which identifies 159 | // the hostnames served by the instances in the group. 160 | type TunnelGroup struct { 161 | Hosts []string `json:"hosts,omitempty" yaml:"hosts,omitempty"` 162 | Authentication TunnelGroupAuthentication `json:"authentication,omitempty" yaml:"authentication,omitempty"` 163 | } 164 | 165 | type TunnelGroupAuthentication struct { 166 | Basic *AuthenticationBasic `json:"basic,omitempty" yaml:"basic,omitempty"` 167 | Bearer *AuthenticationBearer `json:"bearer,omitempty" yaml:"bearer,omitempty"` 168 | External *AuthenticationExternal `json:"external,omitempty" yaml:"external,omitempty"` 169 | } 170 | 171 | type AuthenticationBasic struct { 172 | Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` 173 | Username string `json:"username,omitempty" yaml:"username,omitempty"` 174 | Password string `json:"password,omitempty" yaml:"password,omitempty"` 175 | } 176 | 177 | func (a *AuthenticationBasic) scheme() string { return a.Scheme } 178 | 179 | func (a *AuthenticationBasic) Validate(context.Context) error { 180 | if a == nil { 181 | return nil 182 | } 183 | 184 | if a.Scheme == "" { 185 | a.Scheme = "Basic" 186 | } 187 | 188 | if a.Username == "" { 189 | return errors.New("basic: username must be non-empty string") 190 | } 191 | 192 | if a.Password == "" { 193 | return errors.New("basic: password must be non-empty string") 194 | } 195 | 196 | return nil 197 | } 198 | 199 | type AuthenticationBearer struct { 200 | Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` 201 | Token string `json:"token,omitempty" yaml:"token,omitempty"` 202 | TokenPath string `json:"tokenPath,omitempty" yaml:"tokenPath,omitempty"` 203 | HashedToken string `json:"hashedToken,omitempty" yaml:"hashedToken,omitempty"` 204 | HashedTokenPath string `json:"hashedTokenPath,omitempty" yaml:"hashedTokenPath,omitempty"` 205 | source auth.BearerSource `json:"-" yaml:"-"` 206 | } 207 | 208 | func (a *AuthenticationBearer) scheme() string { return a.Scheme } 209 | 210 | func (a *AuthenticationBearer) Validate(ctx context.Context) (err error) { 211 | if a == nil { 212 | return nil 213 | } 214 | 215 | if a.Scheme == "" { 216 | a.Scheme = "Bearer" 217 | } 218 | 219 | var ( 220 | token []byte 221 | path string 222 | hashed bool 223 | ) 224 | 225 | switch { 226 | case a.Token != "": 227 | token = []byte(a.Token) 228 | case a.TokenPath != "": 229 | path = a.TokenPath 230 | case a.HashedToken != "": 231 | token = []byte(a.HashedToken) 232 | hashed = true 233 | case a.HashedTokenPath != "": 234 | path = a.HashedTokenPath 235 | hashed = true 236 | default: 237 | return errors.New("bearer: one of token, tokenPath, hashedToken or hashedTokenPath must be non-empty string") 238 | } 239 | 240 | setStaticTokenSource := func(token []byte) (err error) { 241 | if !hashed { 242 | a.source = auth.StaticBearerSource(token) 243 | return 244 | } 245 | 246 | a.source, err = auth.HashedStaticBearerSource(token) 247 | return err 248 | } 249 | 250 | if len(token) > 0 { 251 | return setStaticTokenSource(token) 252 | } 253 | 254 | u, err := url.Parse(path) 255 | if err != nil { 256 | return err 257 | } 258 | 259 | // otherwise, assume the token has been defined at some path 260 | switch u.Scheme { 261 | case "", "file": 262 | token, err = os.ReadFile(path) 263 | if err != nil { 264 | return fmt.Errorf("bearer: reading token path %q: %w", path, err) 265 | } 266 | 267 | return setStaticTokenSource(token) 268 | case "k8s": 269 | if u.Host == "secret" { 270 | ns, name, key, err := k8sObjectNamespaceNameKey(u.Path) 271 | if err != nil { 272 | return fmt.Errorf("unexpected k8s secret path: %w", err) 273 | } 274 | 275 | source, err := getK8sSource(ctx) 276 | if err != nil { 277 | return err 278 | } 279 | 280 | a.source, err = source.newSecretBearerSource(ctx, ns, name, key, true) 281 | if err != nil { 282 | return err 283 | } 284 | 285 | return nil 286 | } 287 | 288 | return fmt.Errorf("unsupported k8s resource: %q (expected [secret])", u.Host) 289 | default: 290 | return fmt.Errorf("unsupported path scheme: %q (expected one of [file k8s])", u.Scheme) 291 | } 292 | } 293 | 294 | type AuthenticationExternal struct { 295 | Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` 296 | Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` 297 | } 298 | 299 | func (a *AuthenticationExternal) scheme() string { return a.Scheme } 300 | 301 | func (a *AuthenticationExternal) Validate(context.Context) error { 302 | if a == nil { 303 | return nil 304 | } 305 | 306 | if a.Scheme == "" { 307 | a.Scheme = "Bearer" 308 | } 309 | 310 | if a.Endpoint == "" { 311 | return errors.New("external: endpoint must be non-empty string") 312 | } 313 | 314 | if _, err := url.Parse(a.Endpoint); err != nil { 315 | return fmt.Errorf("external: parsing endpoint as URL: %w", err) 316 | } 317 | 318 | return nil 319 | } 320 | 321 | func (g TunnelGroup) Validate(ctx context.Context) error { 322 | auth := g.Authentication 323 | if auth.Basic == nil && auth.Bearer == nil && auth.External == nil { 324 | slog.Warn("No authentication has been configured for tunnel (insecure)") 325 | } 326 | 327 | schemes := map[string]struct{}{} 328 | for _, s := range []validator{auth.Basic, auth.Bearer, auth.External} { 329 | // it is not enough to do a simple == nil check here because of how Go 330 | // represents variables of interface types as a tuple of concrete type 331 | // and its value under the hood. 332 | // we have to use the reflect package to assert that the value is nil 333 | // and to ignore the fact that each validator implementation has a different 334 | // yet present concrete type. 335 | if reflect.ValueOf(s).IsNil() { 336 | continue 337 | } 338 | 339 | if err := s.Validate(ctx); err != nil { 340 | return err 341 | } 342 | 343 | if _, ok := schemes[s.scheme()]; ok { 344 | return fmt.Errorf("only one authentication strategy per scheme allowed: %q has duplicates", s.scheme()) 345 | } 346 | 347 | schemes[s.scheme()] = struct{}{} 348 | } 349 | 350 | return nil 351 | } 352 | 353 | type validator interface { 354 | scheme() string 355 | Validate(context.Context) error 356 | } 357 | 358 | type Level slog.Level 359 | 360 | func (l Level) String() string { 361 | return slog.Level(l).String() 362 | } 363 | 364 | func (l *Level) Set(v string) error { 365 | level := slog.Level(*l) 366 | if err := level.UnmarshalText([]byte(v)); err != nil { 367 | return err 368 | } 369 | 370 | *l = Level(level) 371 | return nil 372 | } 373 | 374 | func k8sObjectNamespaceNameKey(path string) (namespace, name, key string, err error) { 375 | parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) 376 | if len(parts) < 3 { 377 | err = fmt.Errorf("expected path form [namespace]/[name]/[key] found: %q", path) 378 | return 379 | } 380 | 381 | return parts[0], parts[1], parts[2], nil 382 | } 383 | -------------------------------------------------------------------------------- /internal/config/fsnotify.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/fsnotify/fsnotify" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | func watchFSNotify(ctx context.Context, ch chan<- *TunnelGroups, path string, watch bool) error { 14 | groups, err := buildTunnelGroupsAtPath(ctx, path) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | // feed initial channel group 20 | ch <- groups 21 | 22 | if !watch { 23 | return nil 24 | } 25 | 26 | watcher, err := fsnotify.NewWatcher() 27 | if err != nil { 28 | return err 29 | } 30 | defer watcher.Close() 31 | 32 | go func() { 33 | defer close(ch) 34 | 35 | for { 36 | select { 37 | case <-ctx.Done(): 38 | return 39 | case event, ok := <-watcher.Events: 40 | if !ok { 41 | return 42 | } 43 | 44 | slog.Debug("Watcher event", "event", event) 45 | 46 | if !(event.Has(fsnotify.Remove)) { 47 | continue 48 | } 49 | 50 | groups, err := buildTunnelGroupsAtPath(ctx, path) 51 | if err != nil { 52 | slog.Error("reading tunnel groups: %w", err) 53 | continue 54 | } 55 | 56 | ch <- groups 57 | 58 | // remove and re-add as the file has been moved atomically 59 | watcher.Remove(event.Name) 60 | watcher.Add(path) 61 | 62 | case err, ok := <-watcher.Errors: 63 | if !ok { 64 | return 65 | } 66 | 67 | slog.Error("watching tunnel groups", "error", err) 68 | } 69 | } 70 | }() 71 | 72 | if err := watcher.Add(path); err != nil { 73 | return err 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func buildTunnelGroupsAtPath(ctx context.Context, path string) (*TunnelGroups, error) { 80 | fi, err := os.Open(path) 81 | if err != nil { 82 | return nil, fmt.Errorf("reading tunnel groups: %w", err) 83 | } 84 | 85 | defer fi.Close() 86 | 87 | var groups TunnelGroups 88 | if err := yaml.NewDecoder(fi).Decode(&groups); err != nil { 89 | return nil, fmt.Errorf("decoding tunnel groups: %w", err) 90 | } 91 | 92 | if err := groups.Validate(ctx); err != nil { 93 | return nil, fmt.Errorf("validating tunnel groups: %w", err) 94 | } 95 | 96 | return &groups, nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/config/k8s.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | 12 | "gopkg.in/yaml.v2" 13 | v1 "k8s.io/api/core/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/fields" 16 | "k8s.io/client-go/informers" 17 | "k8s.io/client-go/informers/core" 18 | "k8s.io/client-go/kubernetes" 19 | "k8s.io/client-go/rest" 20 | "k8s.io/client-go/tools/cache" 21 | "k8s.io/client-go/tools/clientcmd" 22 | ) 23 | 24 | type k8sSource struct { 25 | logger *slog.Logger 26 | factory informers.SharedInformerFactory 27 | } 28 | 29 | func newK8sSource(ctx context.Context) (*k8sSource, error) { 30 | config, err := k8sConfig() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | client, err := kubernetes.NewForConfig(config) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return newK8sSourceForClientset(ctx, client), nil 41 | } 42 | 43 | func newK8sSourceForClientset(ctx context.Context, clientset kubernetes.Interface) *k8sSource { 44 | src := &k8sSource{ 45 | logger: slog.With("component", "k8s_source"), 46 | factory: informers.NewSharedInformerFactory(clientset, 0), 47 | } 48 | 49 | src.logger.Debug("Starting Informer Factory") 50 | 51 | src.factory.Start(ctx.Done()) 52 | 53 | return src 54 | } 55 | 56 | func (s *k8sSource) watchConfigMap(ctx context.Context, ch chan<- *TunnelGroups, namespace, name, key string) error { 57 | informer := core.New(s.factory, namespace, func(lo *metav1.ListOptions) { 58 | lo.FieldSelector = fields.OneTermEqualSelector("metadata.name", name).String() 59 | }).V1().ConfigMaps().Informer() 60 | 61 | informer.AddEventHandler(TypedEventHandler[*v1.ConfigMap]{ 62 | logger: slog.With("resource", "configmap"), 63 | AddFunc: func(cm *v1.ConfigMap) { 64 | tg, err := buildTunnelGroupsFromConfigMap(ctx, cm, key) 65 | if err != nil { 66 | s.logger.Error("Converting ConfigMap into tunnel groups", "error", err) 67 | return 68 | } 69 | 70 | ch <- tg 71 | }, 72 | UpdateFunc: func(_, cm *v1.ConfigMap) { 73 | tg, err := buildTunnelGroupsFromConfigMap(ctx, cm, key) 74 | if err != nil { 75 | s.logger.Error("Converting ConfigMap into tunnel groups", "error", err) 76 | return 77 | } 78 | 79 | ch <- tg 80 | }, 81 | }) 82 | 83 | s.logger.Debug("Starting ConfigMap Watcher") 84 | 85 | go informer.Run(ctx.Done()) 86 | 87 | // wait for initial list to complete and watchers to begin before proceeding 88 | s.logger.Debug("Waiting for Cache Sync") 89 | s.factory.WaitForCacheSync(ctx.Done()) 90 | 91 | return nil 92 | } 93 | 94 | func buildTunnelGroupsFromConfigMap(ctx context.Context, cfg *v1.ConfigMap, key string) (*TunnelGroups, error) { 95 | raw, ok := cfg.Data[key] 96 | if !ok { 97 | return nil, fmt.Errorf("key %q not found in ConfigMap", key) 98 | } 99 | 100 | var groups TunnelGroups 101 | if err := yaml.Unmarshal([]byte(raw), &groups); err != nil { 102 | return nil, fmt.Errorf("decoding tunnel groups: %w", err) 103 | } 104 | 105 | if err := groups.Validate(ctx); err != nil { 106 | return nil, fmt.Errorf("validating tunnel groups: %w", err) 107 | } 108 | 109 | return &groups, nil 110 | } 111 | 112 | type secretBearerSource struct { 113 | informer cache.SharedIndexInformer 114 | namespace string 115 | name string 116 | key string 117 | hashed bool 118 | } 119 | 120 | func (s *k8sSource) newSecretBearerSource(ctx context.Context, namespace, name, key string, hashed bool) (*secretBearerSource, error) { 121 | source := &secretBearerSource{namespace: namespace, name: name, key: key, hashed: hashed} 122 | 123 | source.informer = core.New(s.factory, namespace, nil).V1().Secrets().Informer() 124 | source.informer.AddEventHandler(TypedEventHandler[*v1.Secret]{ 125 | logger: s.logger.With("resource", "secret"), 126 | }) 127 | 128 | // the informer may have been cached by the factory and already started 129 | // this avoids attempting to double run the informer (this just produces a warning) 130 | if source.informer.HasSynced() { 131 | return source, nil 132 | } 133 | 134 | s.logger.Debug("Starting secret watcher") 135 | 136 | go source.informer.Run(ctx.Done()) 137 | 138 | // wait for initial list to complete and watchers to begin before proceeding 139 | for !source.informer.HasSynced() { 140 | if err := ctx.Err(); err != nil { 141 | return nil, err 142 | } 143 | 144 | s.logger.Debug("Waiting for cache sync") 145 | 146 | s.factory.WaitForCacheSync(ctx.Done()) 147 | } 148 | 149 | s.logger.Debug("Finished waiting for sync") 150 | 151 | return source, nil 152 | } 153 | 154 | // GetCredential returns a bearer credential 155 | // HandleBearerSource expects all tokens to have been hashed with SHA256 156 | func (s *secretBearerSource) GetCredential() ([]byte, error) { 157 | secret := &v1.Secret{ 158 | ObjectMeta: metav1.ObjectMeta{ 159 | Namespace: s.namespace, 160 | Name: s.name, 161 | }, 162 | } 163 | 164 | obj, exists, err := s.informer.GetStore().Get(secret) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | if !exists { 170 | return nil, fmt.Errorf("secret not found: %s/%s", secret.ObjectMeta.GetNamespace(), secret.ObjectMeta.GetName()) 171 | } 172 | 173 | secret, ok := obj.(*v1.Secret) 174 | if !ok { 175 | return nil, fmt.Errorf("secret unexpected type: %T", obj) 176 | } 177 | 178 | token, ok := secret.Data[s.key] 179 | if !ok { 180 | return nil, errors.New("secret data empty") 181 | } 182 | 183 | if s.hashed { 184 | dst := make([]byte, hex.DecodedLen(len(token))) 185 | _, err := hex.Decode(dst, token) 186 | return dst, err 187 | } 188 | 189 | dst := sha256.Sum256(token) 190 | return dst[:], nil 191 | } 192 | 193 | func k8sConfig() (*rest.Config, error) { 194 | if cfg := os.Getenv("KUBECONFIG"); cfg != "" { 195 | return clientcmd.BuildConfigFromFlags("", cfg) 196 | } 197 | 198 | return rest.InClusterConfig() 199 | } 200 | 201 | type TypedEventHandler[T any] struct { 202 | logger *slog.Logger 203 | AddFunc func(T) 204 | UpdateFunc func(T, T) 205 | DeleteFunc func(T) 206 | } 207 | 208 | // OnAdd calls AddFunc if it's not nil. 209 | func (t TypedEventHandler[T]) OnAdd(obj interface{}, isInInitialList bool) { 210 | if t.logger != nil { 211 | t.logger.Debug("Resource added") 212 | } 213 | 214 | if t.AddFunc != nil { 215 | t.AddFunc(obj.(T)) 216 | } 217 | } 218 | 219 | // OnUpdate calls UpdateFunc if it's not nil. 220 | func (t TypedEventHandler[T]) OnUpdate(oldObj, newObj interface{}) { 221 | if t.logger != nil { 222 | t.logger.Debug("Resource updated") 223 | } 224 | 225 | if t.UpdateFunc != nil { 226 | var oldT T 227 | if oldObj != nil { 228 | oldT = oldObj.(T) 229 | } 230 | 231 | var newT T 232 | if newObj != nil { 233 | newT = newObj.(T) 234 | } 235 | 236 | t.UpdateFunc(oldT, newT) 237 | } 238 | } 239 | 240 | // OnDelete calls DeleteFunc if it's not nil. 241 | func (t TypedEventHandler[T]) OnDelete(obj interface{}) { 242 | if t.logger != nil { 243 | t.logger.Debug("Resource deleted") 244 | } 245 | 246 | if t.DeleteFunc != nil { 247 | t.DeleteFunc(obj.(T)) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /internal/config/k8s_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "io" 7 | "log/slog" 8 | "strings" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "gopkg.in/yaml.v3" 16 | corev1 "k8s.io/api/core/v1" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/client-go/kubernetes/fake" 19 | ) 20 | 21 | func Test_k8sSource_watchConfigMap(t *testing.T) { 22 | logger := slog.New(slog.NewTextHandler(newTestWriter(t), &slog.HandlerOptions{ 23 | Level: slog.LevelDebug, 24 | })) 25 | 26 | slog.SetDefault(logger) 27 | 28 | var ( 29 | ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) 30 | clientset = fake.NewSimpleClientset( 31 | &corev1.ConfigMap{ 32 | ObjectMeta: metav1.ObjectMeta{ 33 | Name: "reverst", 34 | Namespace: "default", 35 | Labels: map[string]string{}, 36 | }, 37 | Data: map[string]string{ 38 | "groups.yml": mustMarshal(t, map[string]any{ 39 | "groups": map[string]any{ 40 | "localhost": map[string]any{ 41 | "hosts": []string{"localhost"}, 42 | "authentication": map[string]any{ 43 | "basic": map[string]any{"username": "user", "password": "pass"}, 44 | }, 45 | }, 46 | }, 47 | }), 48 | }, 49 | }, 50 | ) 51 | ) 52 | t.Cleanup(cancel) 53 | 54 | t.Log("Created clientset") 55 | 56 | var ( 57 | src = newK8sSourceForClientset(ctx, clientset) 58 | groups = make(chan *TunnelGroups) 59 | ) 60 | 61 | t.Log("Created k8s source") 62 | 63 | err := src.watchConfigMap(ctx, groups, "default", "reverst", "groups.yml") 64 | require.NoError(t, err) 65 | 66 | t.Log("Started watcher") 67 | 68 | expected := &TunnelGroups{ 69 | Groups: map[string]TunnelGroup{ 70 | "localhost": { 71 | Hosts: []string{"localhost"}, 72 | Authentication: TunnelGroupAuthentication{ 73 | Basic: &AuthenticationBasic{ 74 | Scheme: "Basic", 75 | Username: "user", 76 | Password: "pass", 77 | }, 78 | }, 79 | }, 80 | }, 81 | } 82 | select { 83 | case <-time.After(5 * time.Second): 84 | t.Fatal("timed out waiting for group") 85 | case group := <-groups: 86 | assert.Equal(t, expected, group) 87 | } 88 | } 89 | 90 | func Test_k8sSource_secretBearerSource(t *testing.T) { 91 | logger := slog.New(slog.NewTextHandler(newTestWriter(t), &slog.HandlerOptions{ 92 | Level: slog.LevelDebug, 93 | })) 94 | 95 | slog.SetDefault(logger) 96 | 97 | var ( 98 | ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) 99 | clientset = fake.NewSimpleClientset( 100 | &corev1.Secret{ 101 | ObjectMeta: metav1.ObjectMeta{ 102 | Namespace: "default", 103 | Name: "credentials", 104 | Labels: map[string]string{}, 105 | }, 106 | Data: map[string][]byte{ 107 | "token": []byte("sometokenvalue"), 108 | }, 109 | }, 110 | ) 111 | ) 112 | t.Cleanup(cancel) 113 | 114 | t.Log("Created clientset") 115 | 116 | src := newK8sSourceForClientset(ctx, clientset) 117 | 118 | t.Log("Created k8s source") 119 | 120 | bearerSource, err := src.newSecretBearerSource(ctx, "default", "credentials", "token", false) 121 | require.NoError(t, err) 122 | 123 | token, err := bearerSource.GetCredential() 124 | require.NoError(t, err) 125 | 126 | expected := sha256.Sum256([]byte("sometokenvalue")) 127 | assert.Equal(t, expected[:], token) 128 | 129 | updated := make(chan struct{}) 130 | bearerSource.informer.AddEventHandler(TypedEventHandler[*corev1.Secret]{ 131 | UpdateFunc: func(s1, s2 *corev1.Secret) { 132 | close(updated) 133 | }, 134 | }) 135 | 136 | _, err = clientset.CoreV1().Secrets("default").Update(ctx, &corev1.Secret{ 137 | ObjectMeta: metav1.ObjectMeta{ 138 | Namespace: "default", 139 | Name: "credentials", 140 | Labels: map[string]string{}, 141 | }, 142 | Data: map[string][]byte{ 143 | "token": []byte("somenewtokenvalue"), 144 | }, 145 | }, metav1.UpdateOptions{}) 146 | require.NoError(t, err) 147 | 148 | select { 149 | case <-time.After(5 * time.Second): 150 | t.Fatal("timed out waiting for secret update") 151 | case <-updated: 152 | } 153 | 154 | token, err = bearerSource.GetCredential() 155 | require.NoError(t, err) 156 | 157 | expected = sha256.Sum256([]byte("somenewtokenvalue")) 158 | assert.Equal(t, expected[:], token) 159 | } 160 | 161 | func mustMarshal(t *testing.T, v map[string]any) string { 162 | t.Helper() 163 | 164 | m, err := yaml.Marshal(v) 165 | require.NoError(t, err) 166 | 167 | return string(m) 168 | } 169 | 170 | type testWriter struct { 171 | t *testing.T 172 | 173 | mu sync.Mutex 174 | err error 175 | } 176 | 177 | func newTestWriter(t *testing.T) *testWriter { 178 | t.Helper() 179 | wr := &testWriter{t: t} 180 | t.Cleanup(func() { 181 | wr.mu.Lock() 182 | wr.err = io.EOF 183 | wr.mu.Unlock() 184 | }) 185 | return wr 186 | } 187 | 188 | func (t *testWriter) Write(v []byte) (int, error) { 189 | t.t.Helper() 190 | t.mu.Lock() 191 | defer t.mu.Unlock() 192 | 193 | if t.err != nil { 194 | return 0, t.err 195 | } 196 | 197 | t.t.Log(strings.TrimSpace(string(v))) 198 | return len(v), nil 199 | } 200 | -------------------------------------------------------------------------------- /internal/roundrobbin/set.go: -------------------------------------------------------------------------------- 1 | package roundrobbin 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "slices" 7 | "sync" 8 | "sync/atomic" 9 | ) 10 | 11 | type Set[T comparable] struct { 12 | mu sync.RWMutex 13 | last atomic.Uint64 14 | entries []T 15 | evicted func(T) 16 | } 17 | 18 | func WithOnEvict[T comparable](fn func(T)) func(*Set[T]) { 19 | return func(s *Set[T]) { 20 | s.evicted = fn 21 | } 22 | } 23 | 24 | func NewSet[T comparable](opts ...func(*Set[T])) *Set[T] { 25 | set := &Set[T]{} 26 | 27 | for _, opt := range opts { 28 | opt(set) 29 | } 30 | 31 | return set 32 | } 33 | 34 | func (s *Set[T]) Register(ctx context.Context, t T) { 35 | s.mu.Lock() 36 | defer s.mu.Unlock() 37 | 38 | s.entries = append(s.entries, t) 39 | 40 | // start a goroutine which removes the instance from the set 41 | // when the context is closed 42 | go func() { 43 | select { 44 | case <-ctx.Done(): 45 | s.Remove(t) 46 | } 47 | }() 48 | } 49 | 50 | func (s *Set[T]) Remove(t T) { 51 | var evicted bool 52 | 53 | s.mu.Lock() 54 | defer func() { 55 | s.mu.Unlock() 56 | 57 | if s.evicted != nil && evicted { 58 | s.evicted(t) 59 | } 60 | }() 61 | 62 | slog.Debug("roundrobbin set: removing entry") 63 | 64 | s.entries = slices.DeleteFunc(s.entries, func(rt T) bool { 65 | evicted = evicted || rt == t 66 | return rt == t 67 | }) 68 | } 69 | 70 | func (s *Set[T]) Next(ctx context.Context) (t T, ok bool, err error) { 71 | s.mu.RLock() 72 | defer s.mu.RUnlock() 73 | 74 | if len(s.entries) == 0 { 75 | return t, false, nil 76 | } 77 | 78 | for { 79 | if err := ctx.Err(); err != nil { 80 | return t, false, err 81 | } 82 | 83 | var ( 84 | observed = s.last.Load() 85 | cur = observed 86 | count = uint64(len(s.entries)) 87 | ) 88 | 89 | if cur >= count { 90 | cur = 0 91 | } 92 | 93 | next := cur + 1 94 | if next >= count { 95 | next = 0 96 | } 97 | 98 | if s.last.CompareAndSwap(observed, next) { 99 | return s.entries[cur], true, nil 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/roundrobbin/set_test.go: -------------------------------------------------------------------------------- 1 | package roundrobbin 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "sync/atomic" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func Test_Set(t *testing.T) { 15 | var ( 16 | a, actx = "a", context.Background() 17 | b, bctx = "b", context.Background() 18 | c, cctx = "c", context.Background() 19 | d, dctx = "d", context.Background() 20 | ) 21 | 22 | var set Set[string] 23 | 24 | set.Register(actx, a) 25 | set.Register(bctx, b) 26 | 27 | cctx, ccancel := context.WithCancel(cctx) 28 | set.Register(cctx, c) 29 | set.Register(dctx, d) 30 | 31 | var ( 32 | expected = []string{"a", "b", "c", "d", "a", "b", "c", "d"} 33 | found []string 34 | ) 35 | 36 | for range expected { 37 | v, ok, err := set.Next(context.Background()) 38 | require.NoError(t, err) 39 | require.True(t, ok, "value unexpectedly not found") 40 | found = append(found, v) 41 | } 42 | 43 | assert.Equal(t, expected, found) 44 | 45 | evicted := make(chan string) 46 | set.evicted = func(s string) { 47 | evicted <- s 48 | } 49 | 50 | ccancel() 51 | 52 | select { 53 | case <-time.After(10 * time.Second): 54 | t.Fatal("timed out waiting for eviction") 55 | case <-evicted: 56 | } 57 | 58 | expected = []string{"a", "b", "d", "a", "b", "d"} 59 | found = []string{} 60 | 61 | for range expected { 62 | v, ok, err := set.Next(context.Background()) 63 | require.NoError(t, err) 64 | require.True(t, ok, "value unexpectedly not found") 65 | found = append(found, v) 66 | } 67 | 68 | assert.Equal(t, expected, found) 69 | } 70 | 71 | func Test_Set_RemoveConcurrent(t *testing.T) { 72 | var ( 73 | a, actx = "a", context.Background() 74 | b, bctx = "b", context.Background() 75 | ) 76 | 77 | var deleted uint32 78 | set := Set[string]{ 79 | evicted: func(s string) { 80 | require.Equal(t, "a", s) 81 | atomic.AddUint32(&deleted, 1) 82 | }, 83 | } 84 | 85 | set.Register(actx, a) 86 | set.Register(bctx, b) 87 | 88 | var wg sync.WaitGroup 89 | 90 | wg.Add(1) 91 | go func() { 92 | defer wg.Done() 93 | for i := 0; i < 100; i++ { 94 | _, ok, err := set.Next(context.Background()) 95 | require.NoError(t, err) 96 | assert.True(t, ok, "set has become unexpectedly empty") 97 | } 98 | }() 99 | 100 | for i := 0; i < 20; i++ { 101 | wg.Add(1) 102 | go func() { 103 | defer wg.Done() 104 | set.Remove("a") 105 | }() 106 | } 107 | 108 | wg.Wait() 109 | 110 | assert.Equal(t, uint32(1), deleted) 111 | } 112 | -------------------------------------------------------------------------------- /internal/server/metrics.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "go.opentelemetry.io/otel/attribute" 8 | prom "go.opentelemetry.io/otel/exporters/prometheus" 9 | "go.opentelemetry.io/otel/metric" 10 | "go.opentelemetry.io/otel/metric/noop" 11 | sdkmetric "go.opentelemetry.io/otel/sdk/metric" 12 | ) 13 | 14 | const ( 15 | meterName = "go.flipt.io/reverst" 16 | 17 | namespace = "reverst" 18 | 19 | tunnelGroupSubsystem = "tunnel_group" 20 | proxySubsystem = "tunnel_group_proxy" 21 | ) 22 | 23 | var ( 24 | tunnelGroupKey = attribute.Key("tunnel_group") 25 | hostKey = attribute.Key("host") 26 | statusKey = attribute.Key("status") 27 | errorKey = attribute.Key("error") 28 | ) 29 | 30 | type metrics struct { 31 | metric.Meter 32 | 33 | tunnelGroupRegistrationsTotal metric.Int64Counter 34 | proxyRequestsHandledTotal metric.Int64Counter 35 | proxyRequestsLatency metric.Float64Histogram 36 | } 37 | 38 | func newMetrics(address string) (m metrics, err error) { 39 | m.Meter = noop.NewMeterProvider().Meter(meterName) 40 | if address != "" { 41 | exporter, err := prom.New() 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(exporter)) 47 | m.Meter = provider.Meter(meterName) 48 | } 49 | 50 | m.tunnelGroupRegistrationsTotal, err = m.Meter.Int64Counter( 51 | prometheus.BuildFQName(namespace, tunnelGroupSubsystem, "registrations_total"), 52 | metric.WithDescription("Total number of registration attempts handled by tunnel group and status code"), 53 | ) 54 | if err != nil { 55 | return 56 | } 57 | 58 | m.proxyRequestsHandledTotal, err = m.Meter.Int64Counter( 59 | prometheus.BuildFQName(namespace, proxySubsystem, "requests_total"), 60 | metric.WithDescription("Total number of requests handled by host and response code"), 61 | ) 62 | if err != nil { 63 | return 64 | } 65 | 66 | m.proxyRequestsLatency, err = m.Meter.Float64Histogram( 67 | prometheus.BuildFQName(namespace, proxySubsystem, "requests_latency"), 68 | metric.WithDescription("Latency of requests per host and response code"), 69 | metric.WithUnit("ms"), 70 | ) 71 | 72 | return 73 | } 74 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net" 11 | "net/http" 12 | "net/http/httputil" 13 | "net/http/pprof" 14 | "net/url" 15 | "strconv" 16 | "sync" 17 | "time" 18 | 19 | "github.com/prometheus/client_golang/prometheus" 20 | "github.com/prometheus/client_golang/prometheus/promhttp" 21 | "github.com/quic-go/quic-go" 22 | "github.com/quic-go/quic-go/http3" 23 | "go.flipt.io/reverst/internal/auth" 24 | "go.flipt.io/reverst/internal/config" 25 | "go.flipt.io/reverst/internal/roundrobbin" 26 | "go.flipt.io/reverst/pkg/protocol" 27 | "go.opentelemetry.io/otel/attribute" 28 | "go.opentelemetry.io/otel/metric" 29 | ) 30 | 31 | // ErrNotFound is returned when a requested tunnel group cannot be located 32 | var ErrNotFound = errors.New("not found") 33 | 34 | type Server struct { 35 | metrics 36 | 37 | conf config.Config 38 | 39 | mu sync.RWMutex 40 | handler protocol.AuthenticationHandler 41 | // trippersByGroup maps tunnel group names onto roundRobbinTripper instances 42 | trippersByGroup map[string]*roundRobbinTripper 43 | // handlersByHost maps host names onto target proxy handlers 44 | handlersByHost map[string]*httputil.ReverseProxy 45 | } 46 | 47 | // New constructs and configures a new reverst Server. 48 | func New(conf config.Config, groupChan <-chan *config.TunnelGroups) (*Server, error) { 49 | groups, ok := <-groupChan 50 | if !ok { 51 | return nil, errors.New("tunnel groups channel closed") 52 | } 53 | 54 | s := &Server{ 55 | conf: conf, 56 | handler: groups.AuthenticationHandler(), 57 | trippersByGroup: map[string]*roundRobbinTripper{}, 58 | handlersByHost: map[string]*httputil.ReverseProxy{}, 59 | } 60 | 61 | var err error 62 | s.metrics, err = newMetrics(conf.ManagementAddress) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | target, err := url.Parse("https://" + conf.TunnelAddress) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | for name, group := range groups.Groups { 73 | slog.Debug("Registering tunnel group", "name", name, "hosts", group.Hosts) 74 | 75 | tripper, err := newRoundRobbinTipper(s.Meter, name) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | s.trippersByGroup[name] = tripper 81 | for _, host := range group.Hosts { 82 | s.handlersByHost[host] = proxyHandler(tripper, target) 83 | } 84 | } 85 | 86 | go func() { 87 | log := slog.With("tunnel_groups_path", conf.TunnelGroups) 88 | defer log.Info("Closing tunnel groups watcher") 89 | 90 | for groups := range groupChan { 91 | log.Debug("Tunnel groups configuration update received") 92 | 93 | func() { 94 | s.mu.Lock() 95 | defer s.mu.Unlock() 96 | 97 | // update authentication handle to account for new groups 98 | s.handler = groups.AuthenticationHandler() 99 | 100 | // add any new trippers and associated clients 101 | for name, group := range groups.Groups { 102 | tripper, ok := s.trippersByGroup[name] 103 | if !ok { 104 | var err error 105 | tripper, err = newRoundRobbinTipper(s.Meter, name) 106 | if err != nil { 107 | slog.Error("Building client transport", "error", err) 108 | continue 109 | } 110 | 111 | s.trippersByGroup[name] = tripper 112 | } 113 | 114 | for _, host := range group.Hosts { 115 | s.handlersByHost[host] = proxyHandler(tripper, target) 116 | } 117 | } 118 | 119 | // remove trippers for now non-existent groups 120 | for name, tripper := range s.trippersByGroup { 121 | if _, ok := groups.Groups[name]; ok { 122 | continue 123 | } 124 | 125 | for name, h := range s.handlersByHost { 126 | if h.Transport == tripper { 127 | delete(s.handlersByHost, name) 128 | } 129 | } 130 | 131 | delete(s.trippersByGroup, name) 132 | } 133 | }() 134 | 135 | log.Info("Tunnel groups updated") 136 | } 137 | }() 138 | 139 | return s, nil 140 | } 141 | 142 | func proxyHandler(tripper http.RoundTripper, target *url.URL) *httputil.ReverseProxy { 143 | handler := httputil.NewSingleHostReverseProxy(target) 144 | handler.Transport = tripper 145 | return handler 146 | } 147 | 148 | func (s *Server) ListenAndServe(ctx context.Context) error { 149 | tlsCert, err := tls.LoadX509KeyPair(s.conf.CertificatePath, s.conf.PrivateKeyPath) 150 | if err != nil { 151 | panic(err) 152 | } 153 | 154 | tlsConfig := &tls.Config{ 155 | Certificates: []tls.Certificate{tlsCert}, 156 | NextProtos: []string{protocol.Name}, 157 | ServerName: s.conf.ServerName, 158 | } 159 | 160 | listener, err := quic.ListenAddrEarly(s.conf.TunnelAddress, tlsConfig, &quic.Config{ 161 | MaxIdleTimeout: s.conf.MaxIdleTimeout, 162 | KeepAlivePeriod: s.conf.KeepAlivePeriod, 163 | }) 164 | if err != nil { 165 | return err 166 | } 167 | defer listener.Close() 168 | 169 | slog.Info("QUIC tunnel listener starting...", "addr", s.conf.TunnelAddress) 170 | 171 | ch := make(chan struct{}) 172 | go func() { 173 | defer close(ch) 174 | 175 | for { 176 | if err := ctx.Err(); err != nil { 177 | slog.Info("Stopping tunnel listener") 178 | return 179 | } 180 | 181 | conn, err := listener.Accept(ctx) 182 | if err != nil { 183 | slog.Error("Error accepting connection", "error", err) 184 | continue 185 | } 186 | 187 | slog.Debug("Accepted connection", "version", conn.ConnectionState().Version) 188 | 189 | if err := s.register(conn); err != nil { 190 | code := protocol.ApplicationError 191 | level := slog.LevelError 192 | if errors.Is(err, auth.ErrUnauthorized) { 193 | code = protocol.ApplicationClientError 194 | level = slog.LevelDebug 195 | } 196 | 197 | slog.Log(ctx, level, "Closing connection", "error", err) 198 | conn.CloseWithError(code, cause(err).Error()) 199 | 200 | continue 201 | } 202 | 203 | go func() { 204 | <-ctx.Done() 205 | conn.CloseWithError(protocol.ApplicationOK, "server closing down") 206 | }() 207 | 208 | slog.Debug("Server registered") 209 | } 210 | }() 211 | 212 | if s.conf.ManagementAddress != "" { 213 | mux := http.NewServeMux() 214 | mux.Handle("/metrics", logHandler(slog.With("component", "management"), promhttp.Handler())) 215 | mux.HandleFunc("/debug/pprof/", pprof.Index) 216 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 217 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) 218 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 219 | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) 220 | 221 | go http.ListenAndServe(s.conf.ManagementAddress, mux) 222 | } 223 | 224 | httpServer := &http.Server{ 225 | Addr: s.conf.HTTPAddress, 226 | Handler: s, 227 | } 228 | 229 | go func() { 230 | <-ctx.Done() 231 | 232 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 233 | defer cancel() 234 | 235 | httpServer.Shutdown(ctx) 236 | }() 237 | 238 | slog.Info("HTTP listener starting...", "addr", httpServer.Addr) 239 | 240 | if err := httpServer.ListenAndServe(); err != nil { 241 | if !errors.Is(err, http.ErrServerClosed) { 242 | return err 243 | } 244 | } 245 | 246 | select { 247 | case <-ch: 248 | return nil 249 | case <-time.After(5 * time.Second): 250 | return errors.New("deadline exceeded waiting for tunnel server shutdown") 251 | } 252 | } 253 | 254 | // ServeHTTP proxies requests onto tunnel endpoints based on the presence 255 | // of and targets defined in the requests X-Forwarded-Host or Host headers. 256 | // These headers are used to identify the target tunnel group and then the request 257 | // is forwarded onto the next available connection in a round-robbin sequence. 258 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 259 | start := time.Now().UTC() 260 | 261 | log := slog.With("method", r.Method, "path", r.URL.Path) 262 | log.Debug("Handling request") 263 | 264 | host, _, perr := net.SplitHostPort(r.Host) 265 | if perr != nil { 266 | host = r.Host 267 | } 268 | 269 | if forwarded := r.Header.Get("X-Forwarded-Host"); forwarded != "" { 270 | host = forwarded 271 | } 272 | 273 | wr := interceptStatus(w) 274 | w = wr 275 | 276 | var err error 277 | defer func() { 278 | attrs := attribute.NewSet( 279 | hostKey.String(host), 280 | statusKey.String(statusCodeToLabel(wr.StatusCode())), 281 | ) 282 | s.proxyRequestsHandledTotal.Add(r.Context(), 1, metric.WithAttributeSet(attrs)) 283 | s.proxyRequestsLatency.Record(r.Context(), float64(time.Since(start))/1e6, metric.WithAttributeSet(attrs)) 284 | log.Debug("Finished handling request", "error", err) 285 | }() 286 | 287 | s.mu.RLock() 288 | handler, ok := s.handlersByHost[host] 289 | s.mu.RUnlock() 290 | if !ok { 291 | log.Debug("Unexpected client host requested", "host", host) 292 | http.Error(w, "bad gateway", http.StatusBadGateway) 293 | return 294 | } 295 | 296 | handler.ServeHTTP(w, r) 297 | 298 | return 299 | } 300 | 301 | // register adds a newly accepted connection onto the identified target tunnel group. 302 | func (s *Server) register(conn quic.EarlyConnection) (err error) { 303 | s.mu.RLock() 304 | defer s.mu.RUnlock() 305 | 306 | stream, err := conn.AcceptStream(conn.Context()) 307 | if err != nil { 308 | return fmt.Errorf("accepting stream: %w", err) 309 | } 310 | 311 | defer stream.Close() 312 | 313 | dec := protocol.NewDecoder[protocol.RegisterListenerRequest](stream) 314 | defer dec.Close() 315 | 316 | req, err := dec.Decode() 317 | if err != nil { 318 | return fmt.Errorf("decoding register listener request: %w", err) 319 | } 320 | 321 | w := &responseWriter{ 322 | enc: protocol.NewEncoder[protocol.RegisterListenerResponse](stream), 323 | } 324 | 325 | defer func() { 326 | w.enc.Close() 327 | 328 | attrs := []attribute.KeyValue{tunnelGroupKey.String(req.TunnelGroup)} 329 | if err != nil { 330 | attrs = append(attrs, errorKey.String(cause(err).Error())) 331 | } 332 | 333 | s.tunnelGroupRegistrationsTotal.Add( 334 | context.Background(), 335 | 1, 336 | metric.WithAttributes(attrs...), 337 | ) 338 | }() 339 | 340 | tripper, ok := s.trippersByGroup[req.TunnelGroup] 341 | if !ok { 342 | return fmt.Errorf("tunnel group: %q: %w", req.TunnelGroup, ErrNotFound) 343 | } 344 | 345 | if err := s.handler.Authenticate(&req); err != nil { 346 | return err 347 | } 348 | 349 | if err := w.write(nil); err != nil { 350 | return fmt.Errorf("encoding register listener response: %w", err) 351 | } 352 | 353 | tripper.register(conn) 354 | 355 | return nil 356 | } 357 | 358 | type responseWriter struct { 359 | enc protocol.Encoder[protocol.RegisterListenerResponse] 360 | } 361 | 362 | func (w *responseWriter) write(body []byte) error { 363 | return w.enc.Encode(&protocol.RegisterListenerResponse{ 364 | Version: protocol.Version, 365 | // deprecated: this is going away and we will always 366 | // just close the connection in the future and explain 367 | // why via the application error code on close 368 | Code: protocol.CodeOK, 369 | Body: body, 370 | }) 371 | } 372 | 373 | type roundRobbinTripper struct { 374 | set *roundrobbin.Set[http.RoundTripper] 375 | 376 | activeConnectionsCount metric.Int64UpDownCounter 377 | 378 | attrs attribute.Set 379 | } 380 | 381 | func newRoundRobbinTipper(meter metric.Meter, tunnelGroup string) (_ *roundRobbinTripper, err error) { 382 | tr := roundRobbinTripper{ 383 | attrs: attribute.NewSet(tunnelGroupKey.String(tunnelGroup)), 384 | } 385 | 386 | tr.activeConnectionsCount, err = meter.Int64UpDownCounter( 387 | prometheus.BuildFQName(namespace, tunnelGroupSubsystem, "active_conn"), 388 | metric.WithDescription("Number of active connections in the tunnel group"), 389 | ) 390 | if err != nil { 391 | return nil, err 392 | } 393 | 394 | tr.set = roundrobbin.NewSet( 395 | roundrobbin.WithOnEvict(func(http.RoundTripper) { 396 | tr.activeConnectionsCount.Add( 397 | context.Background(), 398 | -1, 399 | metric.WithAttributeSet(tr.attrs), 400 | ) 401 | }), 402 | ) 403 | 404 | return &tr, nil 405 | } 406 | 407 | func (r *roundRobbinTripper) register(first quic.EarlyConnection) { 408 | defer r.activeConnectionsCount.Add(context.Background(), 1, metric.WithAttributeSet(r.attrs)) 409 | // connection can only be safely used once as the calling 410 | // client will establish a h3 session (control stream) after each dial 411 | // which can only be safely done once 412 | conns := make(chan quic.EarlyConnection, 1) 413 | conns <- first 414 | close(conns) 415 | 416 | r.set.Register(first.Context(), &http3.RoundTripper{ 417 | Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) { 418 | slog.Debug("Dial", "addr", addr, "remote_addr", first.RemoteAddr()) 419 | 420 | conn, ok := <-conns 421 | if ok { 422 | return conn, nil 423 | } 424 | 425 | first.CloseWithError(protocol.ApplicationError, "Connection closing") 426 | 427 | return nil, net.ErrClosed 428 | }, 429 | }) 430 | } 431 | 432 | func (r *roundRobbinTripper) RoundTrip(req *http.Request) (*http.Response, error) { 433 | defer func() { 434 | if req.Body != nil { 435 | _, _ = io.Copy(io.Discard, req.Body) 436 | _ = req.Body.Close() 437 | } 438 | }() 439 | 440 | for { 441 | rt, ok, err := r.set.Next(req.Context()) 442 | if err != nil { 443 | // can only be context error 444 | continue 445 | } 446 | 447 | if !ok { 448 | return nil, net.ErrClosed 449 | } 450 | 451 | resp, err := rt.RoundTrip(req) 452 | if err != nil { 453 | if errors.Is(err, net.ErrClosed) { 454 | r.set.Remove(rt) 455 | continue 456 | } 457 | } 458 | 459 | return resp, err 460 | } 461 | } 462 | 463 | // statusCodeToLabel normalize HTTP status codes into string labels 464 | // 100s to 1XX 465 | // 200s to 2XX 466 | // and so on 467 | func statusCodeToLabel(code int) string { 468 | if code == 0 { 469 | return "2XX" 470 | } 471 | 472 | if c := strconv.Itoa(code); len(c) > 0 { 473 | return c[:1] + "XX" 474 | } 475 | 476 | return "0XX" 477 | } 478 | 479 | type statusCodeResponseWriter interface { 480 | http.ResponseWriter 481 | StatusCode() int 482 | } 483 | 484 | func interceptStatus(w http.ResponseWriter) statusCodeResponseWriter { 485 | i := &statusInterceptResponseWriter{ResponseWriter: w} 486 | if _, ok := w.(io.ReaderFrom); !ok { 487 | return i 488 | } 489 | 490 | return readerFromDecorator{i} 491 | } 492 | 493 | type readerFromDecorator struct { 494 | *statusInterceptResponseWriter 495 | } 496 | 497 | func (d readerFromDecorator) ReadFrom(r io.Reader) (n int64, err error) { 498 | return d.ResponseWriter.(io.ReaderFrom).ReadFrom(r) 499 | } 500 | 501 | func logHandler(log *slog.Logger, h http.Handler) http.Handler { 502 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 503 | start := time.Now().UTC() 504 | log := log.With("path", r.URL.Path) 505 | log.Debug("Request started") 506 | 507 | wr := &statusInterceptResponseWriter{ResponseWriter: w} 508 | defer func() { 509 | log.Debug("Request finished", "code", wr.code, "ellapsed", time.Since(start)) 510 | }() 511 | 512 | h.ServeHTTP(wr, r) 513 | }) 514 | } 515 | 516 | type statusInterceptResponseWriter struct { 517 | http.ResponseWriter 518 | 519 | code int 520 | } 521 | 522 | func (s *statusInterceptResponseWriter) WriteHeader(code int) { 523 | s.ResponseWriter.WriteHeader(code) 524 | s.code = code 525 | } 526 | 527 | func (s *statusInterceptResponseWriter) StatusCode() int { 528 | return s.code 529 | } 530 | 531 | func cause(err error) (cerr error) { 532 | cerr = err 533 | if err = errors.Unwrap(err); err != nil { 534 | return cause(err) 535 | } 536 | 537 | return 538 | } 539 | -------------------------------------------------------------------------------- /internal/synctyped/synctyped.go: -------------------------------------------------------------------------------- 1 | package synctyped 2 | 3 | import "sync" 4 | 5 | type Map[T any] struct { 6 | sync.Map 7 | } 8 | 9 | func (m *Map[T]) Store(k string, t T) { 10 | m.Map.Store(k, t) 11 | } 12 | 13 | func (m *Map[T]) Load(k string) (t T, ok bool) { 14 | v, ok := m.Map.Load(k) 15 | if !ok { 16 | return t, ok 17 | } 18 | 19 | return v.(T), true 20 | } 21 | -------------------------------------------------------------------------------- /internal/test/integration_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "math/rand" 10 | "net/http" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "go.flipt.io/reverst/client" 17 | "go.flipt.io/reverst/pkg/protocol" 18 | "golang.org/x/sync/errgroup" 19 | ) 20 | 21 | var ( 22 | integrationTest = flag.Bool("integration", false, "enable integration testing") 23 | integrationHost = flag.String("integration-host", "local.example", "Hostname for local integration test revert tunnel") 24 | integrationTunnelPort = flag.Int("integration-tunnel-port", 7171, "Port for connecting to tunnel QUIC server") 25 | integrationHTTPPort = flag.Int("integration-http-port", 8181, "Port for connecting to tunnel HTTP server") 26 | ) 27 | 28 | func TestHelloWorld(t *testing.T) { 29 | if !*integrationTest { 30 | t.Skip("integration testing disabled") 31 | return 32 | } 33 | 34 | var ( 35 | tunnelAddr = fmt.Sprintf("%s:%d", *integrationHost, *integrationTunnelPort) 36 | tunnelHTTPURL = fmt.Sprintf("http://%s:%d", *integrationHost, *integrationHTTPPort) 37 | ) 38 | 39 | ctx, cancel := context.WithCancel(context.Background()) 40 | t.Cleanup(cancel) 41 | 42 | var group errgroup.Group 43 | startServer(t, ctx, &group, tunnelAddr, stringHandler(`{"hello":"world"}`)) 44 | 45 | resp, err := http.Get(tunnelHTTPURL) 46 | require.NoError(t, err) 47 | 48 | defer resp.Body.Close() 49 | 50 | bytes, err := io.ReadAll(resp.Body) 51 | require.NoError(t, err) 52 | assert.Equal(t, `{"hello":"world"}`, string(bytes)) 53 | 54 | cancel() 55 | 56 | _ = group.Wait() 57 | } 58 | 59 | func TestBadRequest(t *testing.T) { 60 | if !*integrationTest { 61 | t.Skip("integration testing disabled") 62 | return 63 | } 64 | 65 | var ( 66 | tunnelAddr = fmt.Sprintf("%s:%d", *integrationHost, *integrationTunnelPort) 67 | tunnelHTTPURL = fmt.Sprintf("http://%s:%d", *integrationHost, *integrationHTTPPort) 68 | ) 69 | 70 | ctx, cancel := context.WithCancel(context.Background()) 71 | t.Cleanup(cancel) 72 | 73 | var group errgroup.Group 74 | startServer(t, ctx, &group, tunnelAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | http.Error(w, "bad request", http.StatusBadRequest) 76 | })) 77 | 78 | resp, err := http.Get(tunnelHTTPURL) 79 | require.NoError(t, err) 80 | defer resp.Body.Close() 81 | 82 | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 83 | 84 | bytes, err := io.ReadAll(resp.Body) 85 | require.NoError(t, err) 86 | assert.Equal(t, "bad request\n", string(bytes)) 87 | 88 | cancel() 89 | 90 | _ = group.Wait() 91 | } 92 | 93 | func TestUnauthorized(t *testing.T) { 94 | if !*integrationTest { 95 | t.Skip("integration testing disabled") 96 | return 97 | } 98 | 99 | var tunnelAddr = fmt.Sprintf("%s:%d", *integrationHost, *integrationTunnelPort) 100 | 101 | ctx, cancel := context.WithCancel(context.Background()) 102 | t.Cleanup(cancel) 103 | 104 | server := &client.Server{ 105 | TunnelGroup: "local.example", 106 | Handler: &http.ServeMux{}, 107 | TLSConfig: &tls.Config{ 108 | InsecureSkipVerify: true, 109 | NextProtos: []string{protocol.Name}, 110 | ServerName: "local.example", 111 | }, 112 | Authenticator: client.BasicAuthenticator("user", "wrongpassword"), 113 | } 114 | 115 | require.ErrorIs(t, server.DialAndServe(ctx, tunnelAddr), client.ErrUnauthorized) 116 | } 117 | 118 | func TestMultipleTunnels(t *testing.T) { 119 | if !*integrationTest { 120 | t.Skip("integration testing disabled") 121 | return 122 | } 123 | 124 | var ( 125 | tunnelAddr = fmt.Sprintf("%s:%d", *integrationHost, *integrationTunnelPort) 126 | tunnelHTTPURL = fmt.Sprintf("http://%s:%d", *integrationHost, *integrationHTTPPort) 127 | ) 128 | 129 | ctx, cancel := context.WithCancel(context.Background()) 130 | t.Cleanup(cancel) 131 | 132 | var group errgroup.Group 133 | startServer(t, ctx, &group, tunnelAddr, stringHandler("a")) 134 | startServer(t, ctx, &group, tunnelAddr, stringHandler("b")) 135 | 136 | var responses []string 137 | for i := 0; i < 10; i++ { 138 | resp, err := http.Get(tunnelHTTPURL) 139 | require.NoError(t, err) 140 | 141 | defer resp.Body.Close() 142 | 143 | bytes, err := io.ReadAll(resp.Body) 144 | require.NoError(t, err) 145 | 146 | responses = append(responses, string(bytes)) 147 | } 148 | 149 | assert.Equal(t, []string{"a", "b", "a", "b", "a", "b", "a", "b", "a", "b"}, responses) 150 | 151 | cancel() 152 | 153 | _ = group.Wait() 154 | } 155 | 156 | func TestConcurrentSlowRequests(t *testing.T) { 157 | if !*integrationTest { 158 | t.Skip("integration testing disabled") 159 | return 160 | } 161 | 162 | var ( 163 | tunnelAddr = fmt.Sprintf("%s:%d", *integrationHost, *integrationTunnelPort) 164 | tunnelHTTPURL = fmt.Sprintf("http://%s:%d", *integrationHost, *integrationHTTPPort) 165 | ) 166 | 167 | ctx, cancel := context.WithCancel(context.Background()) 168 | t.Cleanup(cancel) 169 | 170 | var serverGroup errgroup.Group 171 | startServer(t, ctx, &serverGroup, tunnelAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 172 | time.Sleep(time.Duration(100*rand.Intn(10)) * time.Millisecond) 173 | w.Write([]byte("foo")) 174 | })) 175 | 176 | var clientGroup errgroup.Group 177 | for i := 0; i < 1000; i++ { 178 | clientGroup.Go(func() error { 179 | time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) 180 | 181 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) 182 | defer cancel() 183 | 184 | req, err := http.NewRequestWithContext(ctx, "GET", tunnelHTTPURL, nil) 185 | require.NoError(t, err) 186 | 187 | resp, err := http.DefaultClient.Do(req) 188 | if err != nil { 189 | require.ErrorIs(t, err, context.DeadlineExceeded) 190 | return nil 191 | } 192 | 193 | require.NoError(t, err) 194 | defer resp.Body.Close() 195 | 196 | bytes, err := io.ReadAll(resp.Body) 197 | require.NoError(t, err) 198 | 199 | assert.Equal(t, []byte("foo"), bytes) 200 | return nil 201 | }) 202 | } 203 | 204 | require.NoError(t, clientGroup.Wait()) 205 | 206 | cancel() 207 | 208 | _ = serverGroup.Wait() 209 | } 210 | 211 | type stringHandler string 212 | 213 | func (s stringHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 214 | w.Write([]byte(s)) 215 | } 216 | 217 | func startServer(t *testing.T, ctx context.Context, group *errgroup.Group, tunnelAddr string, handler http.Handler) { 218 | t.Helper() 219 | 220 | mux := &http.ServeMux{} 221 | mux.Handle("/", handler) 222 | 223 | ch := make(chan struct{}) 224 | 225 | server := &client.Server{ 226 | TunnelGroup: "local.example", 227 | Handler: mux, 228 | TLSConfig: &tls.Config{ 229 | InsecureSkipVerify: true, 230 | NextProtos: []string{protocol.Name}, 231 | ServerName: "local.example", 232 | }, 233 | Authenticator: client.BasicAuthenticator("user", "pass"), 234 | OnConnectionReady: func(rlr protocol.RegisterListenerResponse) { 235 | close(ch) 236 | }, 237 | } 238 | 239 | group.Go(func() error { 240 | defer func() { 241 | t.Log("Server closed") 242 | }() 243 | 244 | return server.DialAndServe(ctx, tunnelAddr) 245 | }) 246 | 247 | select { 248 | case <-ch: 249 | case <-time.After(5 * time.Second): 250 | t.Fatal("timed out waiting for tunnel connection") 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /pkg/protocol/protocol.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/quic-go/quic-go" 7 | "github.com/vmihailenco/msgpack/v5" 8 | ) 9 | 10 | const ( 11 | Name = "quic-h3-tunnel" 12 | 13 | Version uint8 = 1 14 | ) 15 | 16 | // deprecated: we're going to reply on ApplicationCode going forward 17 | // and close connections under these application error conditions 18 | // 19 | //go:generate stringer -type=ResponseCode 20 | type ResponseCode uint8 21 | 22 | const ( 23 | CodeOK ResponseCode = iota 24 | CodeBadRequest 25 | CodeNotFound 26 | CodeUnauthorized 27 | CodeServerError 28 | ) 29 | 30 | const ( 31 | // ApplicationOK is returned when the stream or connection is being closed 32 | // intentionally with no error as the client is going away 33 | ApplicationOK = quic.ApplicationErrorCode(0x0) 34 | // ApplicationError is returned when something went wrong 35 | // The client can attempt to reconnect in this situation 36 | ApplicationError = quic.ApplicationErrorCode(0x1) 37 | // ApplicationClientError is return when the something went 38 | // wrong handling a clients request 39 | ApplicationClientError = quic.ApplicationErrorCode(0x2) 40 | ) 41 | 42 | type RegisterListenerRequest struct { 43 | Version uint8 44 | TunnelGroup string 45 | Metadata map[string]string 46 | } 47 | 48 | type RegisterListenerResponse struct { 49 | Version uint8 50 | // deprecated: we're going to rely on ApplicationCode instead 51 | // and always close with connection with the relevant error code 52 | Code ResponseCode 53 | Metadata map[string]string 54 | Body []byte 55 | } 56 | 57 | type AuthenticationHandler interface { 58 | Authenticate(*RegisterListenerRequest) error 59 | } 60 | 61 | type AuthenticationHandlerFunc func(*RegisterListenerRequest) error 62 | 63 | func (r AuthenticationHandlerFunc) Authenticate(req *RegisterListenerRequest) error { 64 | return r(req) 65 | } 66 | 67 | type Decoder[T any] struct { 68 | dec *msgpack.Decoder 69 | } 70 | 71 | func (d Decoder[T]) Close() { 72 | msgpack.PutDecoder(d.dec) 73 | } 74 | 75 | func NewDecoder[T any](rd io.ReadCloser) Decoder[T] { 76 | dec := msgpack.GetDecoder() 77 | dec.Reset(rd) 78 | return Decoder[T]{dec} 79 | } 80 | 81 | func (d Decoder[T]) Decode() (t T, _ error) { 82 | return t, d.dec.Decode(&t) 83 | } 84 | 85 | type Encoder[T any] struct { 86 | enc *msgpack.Encoder 87 | } 88 | 89 | func (e Encoder[T]) Close() { 90 | msgpack.PutEncoder(e.enc) 91 | } 92 | 93 | func NewEncoder[T any](wr io.WriteCloser) Encoder[T] { 94 | enc := msgpack.GetEncoder() 95 | enc.Reset(wr) 96 | return Encoder[T]{enc} 97 | } 98 | 99 | func (e Encoder[T]) Encode(t *T) error { 100 | return e.enc.Encode(t) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/protocol/responsecode_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=ResponseCode"; DO NOT EDIT. 2 | 3 | package protocol 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[CodeOK-0] 12 | _ = x[CodeBadRequest-1] 13 | _ = x[CodeNotFound-2] 14 | _ = x[CodeUnauthorized-3] 15 | _ = x[CodeServerError-4] 16 | } 17 | 18 | const _ResponseCode_name = "CodeOKCodeBadRequestCodeNotFoundCodeUnauthorizedCodeServerError" 19 | 20 | var _ResponseCode_index = [...]uint8{0, 6, 20, 32, 48, 63} 21 | 22 | func (i ResponseCode) String() string { 23 | if i >= ResponseCode(len(_ResponseCode_index)-1) { 24 | return "ResponseCode(" + strconv.FormatInt(int64(i), 10) + ")" 25 | } 26 | return _ResponseCode_name[_ResponseCode_index[i]:_ResponseCode_index[i+1]] 27 | } 28 | --------------------------------------------------------------------------------