├── .github └── workflows │ ├── docker-publish.yml │ └── go.yml ├── .travis.yml ├── .vscode └── settings.json ├── CLAUDE.md ├── Dockerfile ├── GEMINI.md ├── LICENSE ├── README.md ├── a0.go ├── acl.go ├── acl_test.go ├── cmd └── httpd │ └── main.go ├── docker.go ├── docker_test.go ├── errors.go ├── errors_test.go ├── example ├── Makefile ├── allowlist.json ├── error.json ├── fence.json ├── hosts.json ├── myservice.cert ├── myservice.key └── sites.json ├── federate.go ├── federate_test.go ├── go.mod ├── go.sum ├── handler.go ├── handler_test.go ├── learn.go ├── learn_test.go ├── log.go ├── log_test.go ├── masq.go ├── masq_test.go ├── oidc.go ├── oidc_test.go ├── proxy.go ├── proxy_test.go ├── saml.go ├── saml_test.go ├── setup.go ├── setup_test.go ├── token.go ├── token_test.go ├── web.go └── web_test.go /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ main ] 11 | # Publish semver tags as releases. 12 | tags: [ 'v*.*.*' ] 13 | pull_request: 14 | branches: [ main ] 15 | 16 | env: 17 | # Use docker.io for Docker Hub if empty 18 | REGISTRY: ghcr.io 19 | # github.repository as / 20 | IMAGE_NAME: ${{ github.repository }} 21 | 22 | 23 | jobs: 24 | build: 25 | 26 | runs-on: ubuntu-latest 27 | permissions: 28 | contents: read 29 | packages: write 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | 35 | # Login against a Docker registry except on PR 36 | # https://github.com/docker/login-action 37 | - name: Log into registry ${{ env.REGISTRY }} 38 | if: github.event_name != 'pull_request' 39 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 40 | with: 41 | registry: ${{ env.REGISTRY }} 42 | username: ${{ github.actor }} 43 | password: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | # Extract metadata (tags, labels) for Docker 46 | # https://github.com/docker/metadata-action 47 | - name: Extract Docker metadata 48 | id: meta 49 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 50 | with: 51 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 52 | 53 | # Build and push Docker image with Buildx (don't push on PR) 54 | # https://github.com/docker/build-push-action 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 57 | with: 58 | context: . 59 | push: ${{ github.event_name != 'pull_request' }} 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.25' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 1.15.x 3 | install: true 4 | script: 5 | - go test -v -race -coverprofile=coverage.txt -covermode=atomic 6 | after_script: 7 | - bash <(curl -s https://codecov.io/bash) 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.testFlags": ["-gcflags=-l"] 3 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Beyond is a BeyondCorp-inspired authentication proxy that controls access to services beyond your perimeter network. It implements zero-trust security patterns with support for OIDC, OAuth2, and SAML authentication. 8 | 9 | ## Architecture 10 | 11 | ### Core Components 12 | 13 | - **Authentication Layer**: Supports multiple authentication methods (OIDC, OAuth2, SAML) 14 | - `oidc.go` - OpenID Connect implementation 15 | - `saml.go` - SAML authentication handling 16 | - `token.go` - OAuth2 token validation 17 | - `federate.go` - Federation support for cross-domain access 18 | 19 | - **Proxy Layer**: HTTP/WebSocket reverse proxy with smart backend discovery 20 | - `proxy.go` - Core reverse proxy implementation 21 | - `learn.go` - Automatic backend port discovery 22 | - `masq.go` - Host rewriting functionality 23 | 24 | - **Access Control**: Configuration-driven access management 25 | - `acl.go` - Access control lists and allowlisting 26 | - Sites/fence/allowlist configuration via JSON URLs 27 | 28 | - **Specialized Handlers**: 29 | - `docker.go` - Docker registry API compatibility 30 | - `web.go` - Web UI and error pages 31 | - `log.go` - ElasticSearch integration for analytics 32 | 33 | ### Request Flow 34 | 35 | 1. Incoming requests hit the main handler (`handler.go`) 36 | 2. Authentication is verified via session cookies 37 | 3. Unauthenticated requests redirect to `/launch` for auth flow 38 | 4. Authenticated requests are proxied to backend services 39 | 5. Backend ports are learned automatically or from configuration 40 | 41 | ## Development Commands 42 | 43 | ### Building 44 | 45 | ```bash 46 | # Build the main httpd binary 47 | go build ./cmd/httpd 48 | 49 | # Install to $GOPATH/bin 50 | go install ./cmd/httpd 51 | 52 | # Build with Docker 53 | docker build -t beyond . 54 | ``` 55 | 56 | ### Testing 57 | 58 | ```bash 59 | # Run all tests 60 | go test ./... 61 | 62 | # Run tests with coverage 63 | go test -cover ./... 64 | 65 | # Run specific test 66 | go test -run TestHandlerPing 67 | 68 | # Run tests with verbose output 69 | go test -v ./... 70 | ``` 71 | 72 | ### Running 73 | 74 | ```bash 75 | # Run with minimal configuration (see example/ for configs) 76 | go run cmd/httpd/main.go \ 77 | -beyond-host beyond.example.com \ 78 | -cookie-domain .example.com \ 79 | -cookie-key1 "$(openssl rand -hex 16)" \ 80 | -cookie-key2 "$(openssl rand -hex 16)" \ 81 | -oidc-issuer https://your-idp.com/oidc \ 82 | -oidc-client-id your-client-id \ 83 | -oidc-client-secret your-client-secret 84 | 85 | # Run with Docker 86 | docker run --rm -p 80:80 presbrey/beyond httpd [flags] 87 | ``` 88 | 89 | ### Code Quality 90 | 91 | ```bash 92 | # Format code 93 | go fmt ./... 94 | 95 | # Run linter (install: go install golang.org/x/lint/golint@latest) 96 | golint ./... 97 | 98 | # Vet code 99 | go vet ./... 100 | ``` 101 | 102 | ## Key Implementation Notes 103 | 104 | - Session management uses Gorilla sessions with secure cookies 105 | - WebSocket support includes optional compression 106 | - Automatic backend discovery tries HTTPS first, then HTTP ports 107 | - ElasticSearch logging is optional but recommended for analytics 108 | - Docker registry support handles authentication for private registries 109 | - Federation allows trust relationships between Beyond instances 110 | 111 | ## Testing Approach 112 | 113 | - Tests use `testflight` for HTTP testing 114 | - Mock services created with `httptest` 115 | - Test utilities in `test_utils.go` provide shared setup 116 | - Integration tests cover full authentication flows 117 | - Unit tests focus on individual components 118 | 119 | ## Configuration 120 | 121 | The service is configured via command-line flags. Key configurations: 122 | - Authentication (OIDC/SAML) credentials and endpoints 123 | - Cookie settings for session management 124 | - Backend discovery and port preferences 125 | - Optional ElasticSearch for logging 126 | - Access control via JSON configuration URLs -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 2 | 3 | ADD . /go/src/github.com/presbrey/beyond 4 | WORKDIR /go/src/github.com/presbrey/beyond 5 | RUN go install ./cmd/httpd 6 | 7 | WORKDIR /go 8 | CMD ["httpd", "--help"] 9 | -------------------------------------------------------------------------------- /GEMINI.md: -------------------------------------------------------------------------------- 1 | # Beyond - A Go-based Zero-Trust Reverse Proxy 2 | 3 | ## Project Overview 4 | 5 | This project, "beyond," is a reverse proxy written in Go. It's designed to control access to services beyond a perimeter network, inspired by Google's BeyondCorp research. It helps organizations transition to a zero-trust security model, alleviating the need for a traditional VPN. 6 | 7 | The application authenticates users via OpenID Connect (OIDC), SAML, or OAuth2 tokens. It can be configured through command-line flags or by providing URLs to JSON configuration files. It also supports WebSocket proxying, private Docker registries, and logging to Elasticsearch. 8 | 9 | **Key Technologies:** 10 | 11 | * **Go:** The primary programming language. 12 | * **OpenID Connect (OIDC):** For modern authentication. 13 | * **SAML:** For enterprise federation. 14 | * **OAuth2:** For token-based authentication. 15 | * **WebSockets:** For real-time communication. 16 | * **Docker:** For containerized deployment. 17 | * **Elasticsearch:** For analytics and logging. 18 | 19 | **Architecture:** 20 | 21 | The application is a single binary that runs as a web server. It's configured at startup and uses a variety of packages to handle different authentication schemes and proxying logic. The main entry point is in `cmd/httpd/main.go`, which sets up and runs an HTTP server. The core logic for configuration and request handling is in the `beyond` package. 22 | 23 | ## Building and Running 24 | 25 | ### Building from Source 26 | 27 | To build the project from source, you'll need a Go development environment. 28 | 29 | ```bash 30 | go get -u -x github.com/presbrey/beyond 31 | ``` 32 | 33 | ### Running with Docker 34 | 35 | The recommended way to run "beyond" is with Docker. 36 | 37 | ```bash 38 | docker pull presbrey/beyond 39 | ``` 40 | 41 | **Example Usage:** 42 | 43 | Here's a basic example of how to run "beyond" with OIDC authentication: 44 | 45 | ```bash 46 | docker run --rm -p 80:80 presbrey/beyond httpd \ 47 | -beyond-host beyond.example.com \ 48 | -cookie-domain .example.com \ 49 | -oidc-issuer https://your-idp.com/oidc \ 50 | -oidc-client-id your-client-id \ 51 | -oidc-client-secret your-client-secret 52 | ``` 53 | 54 | For more advanced configurations, including SAML, access control, and Docker registry support, refer to the `README.md` file. 55 | 56 | ### Testing 57 | 58 | To run the tests for this project: 59 | 60 | ```bash 61 | go test ./... 62 | ``` 63 | 64 | ## Development Conventions 65 | 66 | * **Configuration:** The application is configured primarily through command-line flags. For more complex configurations, it can fetch JSON files from URLs. 67 | * **Logging:** The application uses the Logrus library for logging. By default, it logs to standard output. For production use, it can be configured to log to Elasticsearch. 68 | * **Dependencies:** The project uses Go modules to manage dependencies. The `go.mod` file lists all the required packages. 69 | * **Code Style:** The code follows standard Go formatting and conventions. 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Cogo Labs, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go](https://github.com/presbrey/beyond/actions/workflows/go.yml/badge.svg)](https://github.com/presbrey/beyond/actions/workflows/go.yml) 2 | [![Docker](https://github.com/presbrey/beyond/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/presbrey/beyond/actions/workflows/docker-publish.yml) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/presbrey/beyond)](https://goreportcard.com/report/github.com/presbrey/beyond) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 5 | 6 | # beyond 7 | Control access to services beyond your perimeter network. Deploy with split-DNS to alleviate VPN in a zero-trust transition. Inspired by Google BeyondCorp research: https://research.google.com/pubs/pub45728.html 8 | 9 | ## Features 10 | - Authenticate via: 11 | - OpenID Connect 12 | - OAuth2 Tokens 13 | - SAMLv2 14 | - Automate Configuration w/ https://your.json 15 | - Customize Nexthop Learning (via Favorite Ports: 443, 80, ...) 16 | - Supports WebSockets 17 | - Supports GitHub Enterprise 18 | - Supports Private Docker Registry APIs (v2) 19 | - Analytics with ElasticSearch 20 | 21 | ## Install 22 | ``` 23 | $ docker pull presbrey/beyond 24 | ``` 25 | or: 26 | ``` 27 | $ go get -u -x github.com/presbrey/beyond 28 | ``` 29 | ## Usage 30 | 31 | ### Example Configurations 32 | 33 | #### Basic OIDC Setup 34 | ```bash 35 | docker run --rm -p 80:80 presbrey/beyond httpd \ 36 | -beyond-host beyond.example.com \ 37 | -cookie-domain .example.com \ 38 | -oidc-issuer https://your-idp.com/oidc \ 39 | -oidc-client-id your-client-id \ 40 | -oidc-client-secret your-client-secret 41 | ``` 42 | 43 | #### OIDC with Access Control 44 | ```bash 45 | docker run --rm -p 80:80 presbrey/beyond httpd \ 46 | -beyond-host beyond.example.com \ 47 | -cookie-domain .example.com \ 48 | -oidc-issuer https://accounts.google.com \ 49 | -oidc-client-id your-google-client-id \ 50 | -oidc-client-secret your-google-client-secret \ 51 | -allowlist-url https://raw.githubusercontent.com/yourorg/config/main/allowlist.json \ 52 | -fence-url https://raw.githubusercontent.com/yourorg/config/main/fence.json \ 53 | -sites-url https://raw.githubusercontent.com/yourorg/config/main/sites.json 54 | ``` 55 | 56 | #### SAML with Docker Registry Support 57 | ```bash 58 | docker run --rm -p 80:80 \ 59 | -v /path/to/certs:/certs \ 60 | presbrey/beyond httpd \ 61 | -beyond-host beyond.example.com \ 62 | -cookie-domain .example.com \ 63 | -saml-metadata-url https://your-idp.com/metadata \ 64 | -saml-cert-file /certs/saml.cert \ 65 | -saml-key-file /certs/saml.key \ 66 | -docker-urls https://harbor.example.com,https://ghcr.example.com 67 | ``` 68 | 69 | #### GitHub Enterprise with Token Auth 70 | ```bash 71 | docker run --rm -p 80:80 presbrey/beyond httpd \ 72 | -beyond-host beyond.example.com \ 73 | -cookie-domain .example.com \ 74 | -oidc-issuer https://github.example.com \ 75 | -oidc-client-id your-github-app-id \ 76 | -oidc-client-secret your-github-app-secret \ 77 | -token-base https://api.github.example.com/user \ 78 | -docker-urls https://docker.pkg.github.example.com 79 | ``` 80 | 81 | #### Production with Elasticsearch Logging 82 | ```bash 83 | docker run --rm -p 80:80 presbrey/beyond httpd \ 84 | -beyond-host beyond.example.com \ 85 | -cookie-domain .example.com \ 86 | -oidc-issuer https://login.example.com \ 87 | -oidc-client-id production-client-id \ 88 | -oidc-client-secret production-client-secret \ 89 | -allowlist-url https://config.example.com/allowlist.json \ 90 | -fence-url https://config.example.com/fence.json \ 91 | -sites-url https://config.example.com/sites.json \ 92 | -hosts-url https://config.example.com/hosts.json \ 93 | -log-elastic https://elasticsearch.example.com:9200 \ 94 | -log-json \ 95 | -error-email support@example.com 96 | ``` 97 | 98 | ### Cookie Key Management 99 | 100 | Beyond requires a single cryptographic key for session cookie encryption. You have two options: 101 | 102 | #### Option 1: Auto-Generated Key (Development/Testing) 103 | If no cookie key is provided, Beyond will automatically generate a secure random key at startup and log it: 104 | ``` 105 | WARN[0000] No cookie key provided, generated random key for this session: 106 | WARN[0000] -cookie-key a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456 107 | WARN[0000] IMPORTANT: Sessions will not persist across restarts. Set explicit key for production use. 108 | ``` 109 | 110 | #### Option 2: Explicit Key (Production) 111 | For production deployments, always set an explicit key to maintain session persistence: 112 | ```bash 113 | # Generate key once and reuse it 114 | export COOKIE_KEY=$(openssl rand -hex 32) 115 | 116 | docker run --rm -p 80:80 presbrey/beyond httpd \ 117 | -cookie-key "$COOKIE_KEY" \ 118 | # ... other parameters 119 | ``` 120 | 121 | ### Host Management 122 | 123 | Beyond supports rewriting backend hostnames to different values and restricting access to only specific hosts. This is useful for legacy system migrations, internal name mapping, and creating secure host allowlists. 124 | 125 | #### Host Rewriting 126 | You can configure host rewriting in two ways: 127 | 128 | **Option 1: Command Line** 129 | Replacement values can include protocols and ports for advanced routing: 130 | ```bash 131 | docker run --rm -p 80:80 presbrey/beyond httpd \ 132 | -hosts-csv "old-api.example.com=https://new-api.example.com:8443,legacy.corp=http://modern.corp.example.com:8080" \ 133 | # ... other parameters 134 | ``` 135 | 136 | **Option 2: JSON Configuration File** 137 | Create a JSON file with hostname mappings. Replacement values can include protocols and ports: 138 | ```json 139 | { 140 | "old-api.example.com": "new-api.example.com", 141 | "legacy.mycompany.net": "https://modern.mycompany.net:8443", 142 | "internal.corp": "http://internal.corp.example.com:8080", 143 | "secure.app": "https://secure.app.example.com" 144 | } 145 | ``` 146 | 147 | Then reference it: 148 | ```bash 149 | docker run --rm -p 80:80 presbrey/beyond httpd \ 150 | -hosts-url https://config.example.com/hosts.json \ 151 | # ... other parameters 152 | ``` 153 | 154 | #### Protocol and Port Support 155 | 156 | When replacement values include full URLs (with `http://` or `https://`), Beyond extracts the protocol and port information for backend connections: 157 | 158 | - **Simple hostname replacement**: `"old.example.com": "new.example.com"` - preserves original protocol and port 159 | - **Protocol specification**: `"legacy.api": "https://modern.api"` - forces HTTPS connection to backend 160 | - **Port specification**: `"internal.app": "http://internal.app:8080"` - connects to specific port 161 | - **Full URL**: `"old.secure": "https://new.secure:9443"` - specifies both protocol and port 162 | 163 | Subdomain matching is preserved: if `api.legacy.com` maps to `https://api.modern.com:8443`, then `service.api.legacy.com` becomes `service.api.modern.com` with HTTPS on port 8443. 164 | 165 | #### Host Allowlist (hosts-only mode) 166 | Use the `-hosts-only` flag to restrict access to only hosts defined in your host mappings: 167 | 168 | ```bash 169 | docker run --rm -p 80:80 presbrey/beyond httpd \ 170 | -hosts-url https://config.example.com/hosts.json \ 171 | -hosts-only \ 172 | # ... other parameters 173 | ``` 174 | 175 | When `hosts-only` is enabled: 176 | - Only hosts in the mapping (from `-hosts-csv` or `-hosts-url`) are allowed 177 | - Requests to unmapped hosts return 403 "Host not allowed" 178 | - Subdomain matching works (e.g., `api.example.com` matches `example.com`) 179 | 180 | Both command-line and URL mappings can be used together - they are merged at startup. 181 | 182 | ### Command Line Options 183 | ``` 184 | $ docker run --rm -p 80:80 presbrey/beyond httpd --help 185 | -401-code int 186 | status to respond when a user needs authentication (default 418) 187 | -404-message string 188 | message to use when backend apps do not respond (default "Please contact the application administrators to setup access.") 189 | -allowlist-url string 190 | URL to site allowlist (eg. https://github.com/myorg/beyond-config/main/raw/allowlist.json) 191 | -beyond-host string 192 | hostname of self (default "beyond.myorg.net") 193 | -cookie-age int 194 | MaxAge setting in seconds (default 21600) 195 | -cookie-domain string 196 | session cookie domain (default ".myorg.net") 197 | -cookie-key string 198 | 64-char hex key for cookie encryption (example: "t8yG1gmeEyeb7pQpw544UeCTyDfPkE6uQ599vrruZRhLFC144thCRZpyHM7qGDjt") 199 | -cookie-name string 200 | session cookie name (default "beyond") 201 | -debug 202 | set debug loglevel (default true) 203 | -docker-auth-scheme string 204 | (only for testing) (default "https") 205 | -docker-url string 206 | when there is only one (legacy option) (default "https://docker.myorg.net") 207 | -docker-urls string 208 | csv of docker server base URLs (default "https://harbor.myorg.net,https://ghcr.myorg.net") 209 | -error-color string 210 | css h1 color for errors (default "#69b342") 211 | -error-email string 212 | address for help (eg. support@mycompany.com) 213 | -error-plain 214 | disable html on error pages 215 | -federate-access string 216 | shared secret, 64 chars, enables federation 217 | -federate-secret string 218 | internal secret, 64 chars 219 | -fence-url string 220 | URL to user fencing config (eg. https://github.com/myorg/beyond-config/main/raw/fence.json) 221 | -ghp-hosts string 222 | CSV of github packages domains (default "ghp.myorg.net") 223 | -header-prefix string 224 | prefix extra headers with this string (default "Beyond") 225 | -health-path string 226 | URL of the health endpoint (default "/healthz/ping") 227 | -health-reply string 228 | response body of the health endpoint (default "ok") 229 | -home-url string 230 | redirect users here from root (default "https://google.com") 231 | -host-masq string 232 | rewrite nexthop hosts (format: from1=to1,from2=to2) 233 | -http string 234 | listen address (default ":80") 235 | -insecure-skip-verify 236 | allow TLS backends without valid certificates 237 | -learn-dial-timeout duration 238 | skip port after this connection timeout (default 8s) 239 | -learn-http-ports string 240 | after HTTPS, try these HTTP ports (csv) (default "80,8080,6000,6060,7000,7070,8000,9000,9200,15672") 241 | -learn-https-ports string 242 | try learning these backend HTTPS ports (csv) (default "443,4443,6443,8443,9443,9090") 243 | -learn-nexthops 244 | set false to require explicit allowlisting (default true) 245 | -log-elastic string 246 | csv of elasticsearch servers 247 | -log-elastic-interval duration 248 | how often to commit bulk updates (default 1s) 249 | -log-elastic-prefix string 250 | insert this on the front of elastic indexes (default "beyond") 251 | -log-elastic-workers int 252 | bulk commit workers (default 3) 253 | -log-http 254 | enable HTTP logging to stdout 255 | -log-json 256 | use json output (logrus) 257 | -log-xff 258 | include X-Forwarded-For in logs (default true) 259 | -oidc-client-id string 260 | OIDC client ID (default "f8b8b020-4ec2-0135-6452-027de1ec0c4e43491") 261 | -oidc-client-secret string 262 | OIDC client secret (default "cxLF74XOeRRFDJbKuJpZAOtL4pVPK1t2XGVrDbe5R") 263 | -oidc-issuer string 264 | OIDC issuer URL provided by IdP (default "https://accounts.google.com") 265 | -saml-cert-file string 266 | SAML SP path to cert.pem (default "example/myservice.cert") 267 | -saml-entity-id string 268 | SAML SP entity ID (blank defaults to beyond-host) 269 | -saml-key-file string 270 | SAML SP path to key.pem (default "example/myservice.key") 271 | -saml-metadata-url string 272 | SAML metadata URL from IdP (blank disables SAML) 273 | -saml-nameid-format string 274 | SAML SP option: {email, persistent, transient, unspecified} (default "email") 275 | -saml-session-key string 276 | SAML attribute to map from session (default "email") 277 | -saml-sign-requests 278 | SAML SP signs authentication requests 279 | -saml-signature-method string 280 | SAML SP option: {sha1, sha256, sha512} 281 | -server-idle-timeout duration 282 | max time to wait for the next request when keep-alives are enabled (default 3m0s) 283 | -server-read-timeout duration 284 | max duration for reading the entire request, including the body (default 1m0s) 285 | -server-write-timeout duration 286 | max duration before timing out writes of the response (default 2m0s) 287 | -sites-url string 288 | URL to allowed sites config (eg. https://github.com/myorg/beyond-config/main/raw/sites.json) 289 | -token-base string 290 | token server URL prefix (eg. https://api.github.com/user) 291 | -token-graphql string 292 | GraphQL URL for auth (eg. https://api.github.com/graphql) 293 | -token-graphql-query string 294 | (default "{\"query\": \"query { viewer { login }}\"}") 295 | -websocket-compression 296 | allow websocket transport compression (gorilla/experimental) 297 | ``` 298 | -------------------------------------------------------------------------------- /a0.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/cogolabs/wait" 8 | ) 9 | 10 | func init() { 11 | // prepend file:lineno 12 | log.SetFlags(log.Flags() | log.Lshortfile) 13 | 14 | // wait for networking 15 | wait.ForNetwork(5, time.Second) 16 | } 17 | -------------------------------------------------------------------------------- /acl.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "net/http" 7 | "path" 8 | "sync" 9 | ) 10 | 11 | var ( 12 | fenceURL = flag.String("fence-url", "", "URL to user fencing config (eg. https://github.com/myorg/beyond-config/main/raw/fence.json)") 13 | sitesURL = flag.String("sites-url", "", "URL to allowed sites config (eg. https://github.com/myorg/beyond-config/main/raw/sites.json)") 14 | allowlistURL = flag.String("allowlist-url", "", "URL to site allowlist (eg. https://github.com/myorg/beyond-config/main/raw/allowlist.json)") 15 | 16 | fence = concurrentMapMapBool{m: map[string]map[string]bool{}} 17 | sites = concurrentMapMapBool{m: map[string]map[string]bool{}} 18 | allowlist = concurrentMapMapBool{m: map[string]map[string]bool{}} 19 | 20 | httpACL = &http.Client{} 21 | ) 22 | 23 | type concurrentMapMapBool struct { 24 | sync.RWMutex 25 | m map[string]map[string]bool 26 | } 27 | 28 | func refreshFence() error { 29 | if *fenceURL == "" { 30 | return nil 31 | } 32 | 33 | resp, err := httpACL.Get(*fenceURL) 34 | if err != nil { 35 | return err 36 | } 37 | defer resp.Body.Close() 38 | d := map[string][]string{} 39 | err = json.NewDecoder(resp.Body).Decode(&d) 40 | if err != nil { 41 | return err 42 | } 43 | for k, v := range d { 44 | if _, ok := fence.m[k]; !ok { 45 | fence.m[k] = map[string]bool{} 46 | } 47 | for _, v := range v { 48 | fence.m[k][v] = true 49 | } 50 | } 51 | return nil 52 | } 53 | 54 | func refreshSites() error { 55 | if *sitesURL == "" { 56 | return nil 57 | } 58 | 59 | resp, err := httpACL.Get(*sitesURL) 60 | if err != nil { 61 | return err 62 | } 63 | defer resp.Body.Close() 64 | d := map[string][]string{} 65 | err = json.NewDecoder(resp.Body).Decode(&d) 66 | if err != nil { 67 | return err 68 | } 69 | sites.Lock() 70 | defer sites.Unlock() 71 | for k, v := range d { 72 | if _, ok := sites.m[k]; !ok { 73 | sites.m[k] = map[string]bool{} 74 | } 75 | for _, v := range v { 76 | sites.m[k][v] = true 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | func refreshAllowlist() error { 83 | if *allowlistURL == "" { 84 | return nil 85 | } 86 | 87 | resp, err := httpACL.Get(*allowlistURL) 88 | if err != nil { 89 | return err 90 | } 91 | defer resp.Body.Close() 92 | allowlist.Lock() 93 | defer allowlist.Unlock() 94 | return json.NewDecoder(resp.Body).Decode(&allowlist.m) 95 | } 96 | 97 | func allowlisted(r *http.Request) bool { 98 | allowlist.RLock() 99 | allow := allowlist.m["host"][r.Host] 100 | hostM := allowlist.m["host:method"][r.Host+":"+r.Method] 101 | paths := allowlist.m["path"] 102 | allowlist.RUnlock() 103 | if allow || hostM { 104 | return true 105 | } 106 | p := path.Clean(r.URL.Path) 107 | for ; p != "/"; p = path.Dir(p) { 108 | if paths[p] { 109 | allow = true 110 | } 111 | } 112 | return allow 113 | } 114 | 115 | func deny(r *http.Request, user string) bool { 116 | fence.RLock() 117 | zones, ok := fence.m[user] 118 | fence.RUnlock() 119 | 120 | if !ok || len(zones) < 1 { 121 | return false 122 | } 123 | 124 | sites.RLock() 125 | m := sites.m 126 | sites.RUnlock() 127 | 128 | for k := range zones { 129 | if m[k]["https://"+r.Host] || m[k]["http://"+r.Host] { 130 | return false 131 | } 132 | } 133 | return true 134 | } 135 | -------------------------------------------------------------------------------- /acl_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func init() { 12 | t := &http.Transport{} 13 | t.RegisterProtocol("file", http.NewFileTransport(http.Dir("/"))) 14 | httpACL.Transport = t 15 | } 16 | 17 | const ( 18 | aclErrorBase = "http://localhost:9999" 19 | ) 20 | 21 | func TestACL(t *testing.T) { 22 | *fenceURL = "" 23 | *sitesURL = "" 24 | *allowlistURL = "" 25 | 26 | assert.NoError(t, refreshFence()) 27 | assert.NoError(t, refreshSites()) 28 | assert.NoError(t, refreshAllowlist()) 29 | 30 | *fenceURL = aclErrorBase 31 | *sitesURL = aclErrorBase 32 | *allowlistURL = aclErrorBase 33 | 34 | assert.Contains(t, refreshFence().Error(), "connection refused") 35 | assert.Contains(t, refreshSites().Error(), "connection refused") 36 | assert.Contains(t, refreshAllowlist().Error(), "connection refused") 37 | 38 | cwd, _ := os.Getwd() 39 | *fenceURL = "file://" + cwd + "/example/error.json" 40 | *sitesURL = "file://" + cwd + "/example/error.json" 41 | *allowlistURL = "file://" + cwd + "/example/error.json" 42 | assert.EqualError(t, refreshFence(), "unexpected EOF") 43 | assert.EqualError(t, refreshSites(), "unexpected EOF") 44 | assert.EqualError(t, refreshAllowlist(), "unexpected EOF") 45 | 46 | *fenceURL = "file://" + cwd + "/example/fence.json" 47 | *sitesURL = "file://" + cwd + "/example/sites.json" 48 | *allowlistURL = "file://" + cwd + "/example/allowlist.json" 49 | assert.NoError(t, Setup()) 50 | 51 | assert.NotEmpty(t, fence.m) 52 | assert.NotEmpty(t, sites.m["git"]) 53 | assert.NotEmpty(t, allowlist.m["host"]) 54 | assert.NotEmpty(t, allowlist.m["path"]) 55 | 56 | reqDeny, _ := http.NewRequest("GET", "https://deny", nil) 57 | assert.True(t, deny(reqDeny, "consultant@gmail.com")) 58 | reqAllow, _ := http.NewRequest("GET", "https://github.com/test", nil) 59 | assert.False(t, deny(reqAllow, "consultant@gmail.com")) 60 | } 61 | -------------------------------------------------------------------------------- /cmd/httpd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/presbrey/beyond" 10 | ) 11 | 12 | var ( 13 | bind = flag.String("http", ":80", "listen address") 14 | 15 | srvReadTimeout = flag.Duration("server-read-timeout", 1*time.Minute, "max duration for reading the entire request, including the body") 16 | srvWriteTimeout = flag.Duration("server-write-timeout", 2*time.Minute, "max duration before timing out writes of the response") 17 | srvIdleTimeout = flag.Duration("server-idle-timeout", 3*time.Minute, "max time to wait for the next request when keep-alives are enabled") 18 | ) 19 | 20 | func main() { 21 | flag.Parse() 22 | 23 | if err := beyond.Setup(); err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | srv := &http.Server{ 28 | Addr: *bind, 29 | Handler: beyond.NewMux(), 30 | 31 | // https://blog.cloudflare.com/exposing-go-on-the-internet/ 32 | ReadTimeout: *srvReadTimeout, 33 | WriteTimeout: *srvWriteTimeout, 34 | IdleTimeout: *srvIdleTimeout, 35 | } 36 | log.Fatal(srv.ListenAndServe()) 37 | } 38 | -------------------------------------------------------------------------------- /docker.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "flag" 8 | "io" 9 | "net/http" 10 | "net/http/httputil" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/gorilla/securecookie" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | // via https://docs.docker.com/registry/spec/auth/token/ 20 | 21 | var ( 22 | dockerBase = flag.String("docker-url", "https://docker.myorg.net", "when there is only one (legacy option)") 23 | dockerURLs = flag.String("docker-urls", "https://harbor.myorg.net,https://ghcr.myorg.net", "csv of docker server base URLs") 24 | dockerScheme = flag.String("docker-auth-scheme", "https", "(only for testing)") 25 | 26 | dockerServers = map[string]*dockerServer{} 27 | 28 | ghpHost = flag.String("ghp-hosts", "ghp.myorg.net", "CSV of github packages domains") 29 | ghpHosts = map[string]bool{} 30 | ) 31 | 32 | func dockerSetup(urls ...string) error { 33 | for _, u := range urls { 34 | dockerURL, err := url.Parse(u) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | srv := new(dockerServer) 40 | srv.host = dockerURL.Hostname() 41 | srv.proxy = httputil.NewSingleHostReverseProxy(dockerURL) 42 | srv.proxy.ModifyResponse = srv.ModifyResponse 43 | dockerServers[srv.host] = srv 44 | } 45 | return nil 46 | } 47 | 48 | type dockerServer struct { 49 | host string 50 | proxy *httputil.ReverseProxy 51 | } 52 | 53 | func (ds *dockerServer) ModifyResponse(resp *http.Response) error { 54 | logRoundtrip(resp) 55 | if ghpHosts[resp.Request.Host] { 56 | return nil 57 | } 58 | 59 | wwwAuth := resp.Header.Get("WWW-Authenticate") 60 | if wwwAuth != "" && strings.Contains(wwwAuth, "/v2/auth") { 61 | resp.Header.Set("WWW-Authenticate", `Bearer realm="`+*dockerScheme+`://`+resp.Request.Host+`/v2/auth",service="`+ds.host+`"`) 62 | } 63 | if resp.Request.URL.Path != "/v2/auth" || resp.StatusCode != 200 { 64 | return nil 65 | } 66 | 67 | // > GET /v2/auth?account=joe&client_id=docker&offline_token=true&service=docker.colofoo.net 68 | // < HTTP/1.1 200 OK 69 | // < {"token": "opaqueXYZ"} 70 | 71 | v := map[string]interface{}{} 72 | err := json.NewDecoder(resp.Body).Decode(&v) 73 | if err == nil { 74 | token, ok := v["token"].(string) 75 | if ok && strings.Contains(token, ".") { 76 | claim64 := strings.Split(token, ".")[1] 77 | data, err := base64.RawStdEncoding.DecodeString(claim64) 78 | if err == nil { 79 | claim := new(dockerClaimSet) 80 | err = json.Unmarshal(data, claim) 81 | if err == nil && claim.Context.Kind == "user" { 82 | v["token"], err = securecookie.EncodeMulti("token", v["token"], store.Codecs...) 83 | if err == nil { 84 | var buf bytes.Buffer 85 | err = json.NewEncoder(&buf).Encode(v) 86 | if err == nil { 87 | // < {"token": "beyondXYZ"} 88 | 89 | resp.Body = io.NopCloser(&buf) 90 | resp.ContentLength = int64(buf.Len()) 91 | resp.Header.Set("Content-Length", strconv.Itoa(buf.Len())) 92 | return nil 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | return err 101 | } 102 | 103 | func (ds *dockerServer) RegisterHandlers(mux *http.ServeMux) { 104 | mux.HandleFunc(ds.host+"/v2/", func(rw http.ResponseWriter, r *http.Request) { 105 | ua := strings.ToLower(r.UserAgent()) 106 | ua1 := strings.HasPrefix(ua, "docker/") 107 | ua2 := strings.HasPrefix(ua, "docker-client/") 108 | ua3 := strings.HasPrefix(ua, "go-") 109 | if !ua1 && !ua2 && !ua3 { 110 | handler(rw, r) 111 | return 112 | } 113 | if *debug { 114 | log.Debugf("[DS] %+v\n", r) 115 | } 116 | ds.ServeHTTP(rw, r) 117 | }) 118 | } 119 | 120 | func (ds *dockerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 121 | if ghpHosts[r.Host] { 122 | if r.Header.Get("Authorization") == "" { 123 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") 124 | w.Header().Set("WWW-Authenticate", `Basic realm="GitHub Package Registry"`) 125 | w.WriteHeader(401) 126 | return 127 | } else if r.URL.Path == "/v2/" { 128 | w.WriteHeader(200) 129 | return 130 | } 131 | ds.proxy.ServeHTTP(w, r) 132 | return 133 | } 134 | 135 | allow := r.URL.Path == "/v2/auth" && len(r.Header.Get("Authorization")) > 0 136 | if !allow { 137 | token := strings.Split(r.Header.Get("Authorization"), " ") 138 | if len(token) > 1 && token[0] == "Bearer" { 139 | bearer := token[1] 140 | err := securecookie.DecodeMulti("token", bearer, &bearer, store.Codecs...) 141 | if err == nil { 142 | allow = true 143 | r.Header.Set("Authorization", "Bearer "+bearer) 144 | } 145 | } 146 | } 147 | if allow { 148 | ds.proxy.ServeHTTP(w, r) 149 | return 150 | } 151 | 152 | w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") 153 | w.Header().Set("WWW-Authenticate", `Bearer realm="`+*dockerScheme+`://`+r.Host+`/v2/auth",service="`+ds.host+`"`) 154 | w.WriteHeader(401) 155 | } 156 | 157 | // https://docs.docker.com/registry/spec/auth/jwt/ 158 | // 159 | // { 160 | // "context": { 161 | // "com.apostille.root": "$disabled" 162 | // }, 163 | // "aud": "docker.colofoo.net", 164 | // "exp": 1593910505, 165 | // "iss": "quay", 166 | // "iat": 1593906905, 167 | // "nbf": 1593906905, 168 | // "sub": "(anonymous)" 169 | // } 170 | // 171 | // { 172 | // "access": [ 173 | // { 174 | // "type": "repository", 175 | // "name": "presbrey/beyond", 176 | // "actions": [ 177 | // "pull" 178 | // ] 179 | // } 180 | // ], 181 | // "context": { 182 | // "entity_kind": "appspecifictoken", 183 | // "kind": "user", 184 | // "version": 2, 185 | // "com.apostille.root": "$disabled", 186 | // "user": "joe", 187 | // "entity_reference": "4ac6f0e7-7bd2-4aea-9a77-738e1b98f22f" 188 | // }, 189 | // "aud": null, 190 | // "exp": 1593911101, 191 | // "iss": "quay", 192 | // "iat": 1593907501, 193 | // "nbf": 1593907501, 194 | // "sub": "joe" 195 | // } 196 | type dockerClaimSet struct { 197 | Access []struct { 198 | Type string `json:"type"` 199 | Name string `json:"name"` 200 | Actions []string `json:"actions"` 201 | } `json:"access"` 202 | Context struct { 203 | EntityKind string `json:"entity_kind"` 204 | Kind string `json:"kind"` 205 | Version int `json:"version"` 206 | ComApostilleRoot string `json:"com.apostille.root"` 207 | User string `json:"user"` 208 | EntityReference string `json:"entity_reference"` 209 | } `json:"context"` 210 | Aud string `json:"aud"` 211 | Exp int `json:"exp"` 212 | Iss string `json:"iss"` 213 | Iat int `json:"iat"` 214 | Nbf int `json:"nbf"` 215 | Sub string `json:"sub"` 216 | } 217 | -------------------------------------------------------------------------------- /docker_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/gorilla/securecookie" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | const dockerToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImU3NDFhODNlODBhMzYwZWVhYmM1NDExZWE3NjE5MmM4NzdjMjdlZjJjYmZmNGQxMWQwZTExN2IyNzRjMDhkNWEifQ.eyJhY2Nlc3MiOltdLCJjb250ZXh0Ijp7ImVudGl0eV9raW5kIjoidXNlciIsImtpbmQiOiJ1c2VyIiwidmVyc2lvbiI6MiwiY29tLmFwb3N0aWxsZS5yb290IjoiJGRpc2FibGVkIiwidXNlciI6ImpvZSIsImVudGl0eV9yZWZlcmVuY2UiOiJjY2VhYmFhOS1mZmM5LTQ4MWUtOTdhZS1iZmMzYTExODMxNDAifSwiYXVkIjpudWxsLCJleHAiOjE1OTM5MTE3MzEsImlzcyI6InF1YXkiLCJpYXQiOjE1OTM5MDgxMzEsIm5iZiI6MTU5MzkwODEzMSwic3ViIjoiam9lIn0.VCZnfwtoJgpEh2U5sAHZlIJAm5pWLnwZVRoH4wnPy6jCQ4ZVw4gUNfZ4xQdBa1nDW-Zc3-iaTGCpVX12bEpaA-b98A7vzN0w6F8HCXij4QXLHGhGibxDO7k5UyPziBQCCXXB960ZVItkyttPsnCFgCPqhAwB5e3acuKKfJgtd-r8qkGXUAKIrk3zJPQvzzb4aI0poBcZh822r4hFY3BvjMlXeR4cKTzdn-96p5ZDj7zCYZanB81vVuENDhxxy_aGLwQWRp3p9GApVgcZCO2WKFDp-P7YYVpcZ5bc7ZlqWBy9RLn6wFGePAykygXwJfdkoeC2ShaHusLTNvqLMoMUYw" 19 | 20 | var ( 21 | dockerHost string 22 | dockerTestServer *httptest.Server 23 | ) 24 | 25 | func init() { 26 | dockerTestServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 | log.Println(r.URL) 28 | w.Header().Set("WWW-Authenticate", "always-overwrite") 29 | switch r.Header.Get("Authorization") { 30 | case "": 31 | w.WriteHeader(401) 32 | fmt.Fprint(w, "{\"errors\":[{\"code\":\"UNAUTHORIZED\",\"detail\":{},") 33 | case "err": 34 | w.WriteHeader(200) 35 | fmt.Fprint(w, `{"token":`) 36 | default: 37 | w.WriteHeader(200) 38 | fmt.Fprint(w, `{"token":"`+dockerToken+`"}`) 39 | } 40 | })) 41 | // Extract just the host part from the test server URL for use in tests 42 | u, _ := url.Parse(dockerTestServer.URL) 43 | dockerHost = u.Host 44 | *dockerBase = dockerTestServer.URL 45 | *dockerScheme = "http" 46 | dockerSetup(*dockerBase) 47 | } 48 | 49 | func TestDockerIE(t *testing.T) { 50 | req, err := http.NewRequest("GET", "http://"+dockerHost+"/", nil) 51 | assert.NoError(t, err) 52 | req.Header.Set("User-Agent", "MSIE") 53 | testMux.ServeHTTP(nil, req) 54 | setCacheControl(nil) 55 | jsRedirect(nil, "") 56 | login(nil, req) 57 | } 58 | 59 | func TestDockerV2(t *testing.T) { 60 | err := dockerSetup(":") 61 | assert.Error(t, err) 62 | 63 | server := httptest.NewServer(testMux) 64 | defer server.Close() 65 | 66 | // Test v2/auth endpoint with no auth 67 | req := httptest.NewRequest("GET", "/v2/auth", nil) 68 | w := httptest.NewRecorder() 69 | testMux.ServeHTTP(w, req) 70 | 71 | resp := w.Result() 72 | assert.Equal(t, 418, resp.StatusCode) 73 | 74 | // Test v2/ endpoint 75 | req = httptest.NewRequest("GET", "/v2/", nil) 76 | req.Host = dockerHost 77 | req.Header.Set("User-Agent", "docker/1.12.6 go/go1.7.4") 78 | w = httptest.NewRecorder() 79 | testMux.ServeHTTP(w, req) 80 | 81 | resp = w.Result() 82 | body, _ := io.ReadAll(resp.Body) 83 | assert.Equal(t, 401, resp.StatusCode) 84 | assert.Equal(t, "", string(body)) 85 | assert.True(t, strings.HasPrefix(resp.Header.Get("WWW-Authenticate"), "Bearer realm=")) 86 | 87 | // Test v2/auth with basic auth 88 | req = httptest.NewRequest("GET", "/v2/auth?account=joe&client_id=docker&offline_token=true&service=docker.colofoo.net", nil) 89 | req.Host = dockerHost 90 | req.SetBasicAuth("joe", "secret0") 91 | req.Header.Set("User-Agent", "docker/1.12.6 go/go1.7.4") 92 | w = httptest.NewRecorder() 93 | testMux.ServeHTTP(w, req) 94 | 95 | resp = w.Result() 96 | body, _ = io.ReadAll(resp.Body) 97 | assert.Equal(t, 200, resp.StatusCode) 98 | assert.True(t, strings.HasPrefix(string(body), "{\"token\":\"")) 99 | 100 | v := map[string]interface{}{} 101 | err = json.Unmarshal(body, &v) 102 | assert.NoError(t, err) 103 | token := v["token"].(string) 104 | assert.NotZero(t, token) 105 | 106 | assert.True(t, len(token) > 500) 107 | err = securecookie.DecodeMulti("token", token, &token, store.Codecs...) 108 | assert.NoError(t, err) 109 | assert.Equal(t, token, dockerToken) 110 | token = v["token"].(string) 111 | 112 | // Test v2/auth with error authorization 113 | req = httptest.NewRequest("GET", "/v2/auth", nil) 114 | req.Host = dockerHost 115 | req.Header.Set("Authorization", "err") 116 | req.Header.Set("User-Agent", "docker/1.12.6 go/go1.7.4") 117 | w = httptest.NewRecorder() 118 | testMux.ServeHTTP(w, req) 119 | 120 | resp = w.Result() 121 | body, _ = io.ReadAll(resp.Body) 122 | assert.Equal(t, 502, resp.StatusCode) 123 | assert.Equal(t, "", string(body)) 124 | 125 | // Test v2/namespaces with valid token 126 | req = httptest.NewRequest("GET", "/v2/namespaces", nil) 127 | req.Host = dockerHost 128 | req.Header.Set("Authorization", "Bearer "+token) 129 | req.Header.Set("User-Agent", "docker/1.12.6 go/go1.7.4") 130 | w = httptest.NewRecorder() 131 | testMux.ServeHTTP(w, req) 132 | 133 | resp = w.Result() 134 | body, _ = io.ReadAll(resp.Body) 135 | assert.Equal(t, 200, resp.StatusCode) 136 | assert.True(t, strings.HasPrefix(string(body), "{\"token\":\"")) 137 | 138 | // Test v2/namespaces with truncated (invalid) token 139 | token = token[:len(token)/2] 140 | req = httptest.NewRequest("GET", "/v2/namespaces", nil) 141 | req.Host = dockerHost 142 | req.Header.Set("Authorization", "Bearer "+token) 143 | req.Header.Set("User-Agent", "docker/1.12.6 go/go1.7.4") 144 | w = httptest.NewRecorder() 145 | testMux.ServeHTTP(w, req) 146 | 147 | resp = w.Result() 148 | body, _ = io.ReadAll(resp.Body) 149 | assert.Equal(t, 401, resp.StatusCode) 150 | assert.Equal(t, "", string(body)) 151 | } 152 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "html/template" 7 | "net/http" 8 | ) 9 | 10 | var ( 11 | errorColor = flag.String("error-color", "#69b342", "css h1 color for errors") 12 | errorEmail = flag.String("error-email", "", "address for help (eg. support@mycompany.com)") 13 | errorPlain = flag.Bool("error-plain", false, "disable html on error pages") 14 | 15 | errorTemplate = template.Must(template.New("error").Parse(` 16 | 17 | 18 | 19 | 20 | {{.code}} - {{.title}} 21 | 22 | 23 | 24 |

{{.title}} Error {{.code}}

{{if .description}}

{{.description}}

{{end}}
25 | {{if .email}}{{end}} 26 | 27 | `)) 28 | ) 29 | 30 | func errorExecute(w http.ResponseWriter, status int, description string) error { 31 | w.WriteHeader(status) 32 | if *errorPlain { 33 | _, err := fmt.Fprintln(w, description) 34 | return err 35 | } 36 | 37 | w.Header().Set("Content-Type", "text/html") 38 | setCacheControl(w) 39 | 40 | data := map[string]interface{}{ 41 | "code": fmt.Sprint(status), 42 | "title": http.StatusText(status), 43 | "color": *errorColor, 44 | } 45 | if description != "" { 46 | data["description"] = description 47 | } 48 | if *errorEmail != "" { 49 | data["email"] = *errorEmail 50 | } 51 | return errorTemplate.Execute(w, data) 52 | } 53 | 54 | func errorHandler(w http.ResponseWriter, status int, description string) { 55 | err := errorExecute(w, status, description) 56 | if err != nil { 57 | WithField("code", status).WithField("err", err.Error()).Error(description) 58 | } 59 | } 60 | 61 | // https://tools.ietf.org/html/rfc6749#section-4.1.2.1 62 | 63 | func errorQuery(w http.ResponseWriter, r *http.Request) { 64 | errorDescription := r.URL.Query().Get("error_description") 65 | 66 | switch r.URL.Query().Get("error") { 67 | 68 | case "invalid_request": 69 | errorHandler(w, 400, errorDescription) 70 | 71 | case "access_denied", "unauthorized_client": 72 | errorHandler(w, 403, errorDescription) 73 | 74 | case "invalid_resource": 75 | errorHandler(w, 404, errorDescription) 76 | 77 | case "server_error": 78 | errorHandler(w, 500, errorDescription) 79 | 80 | case "unsupported_response_type": 81 | errorHandler(w, 501, errorDescription) 82 | 83 | case "temporarily_unavailable": 84 | errorHandler(w, 503, errorDescription) 85 | 86 | default: 87 | if errorDescription == "" { 88 | errorDescription = "Unknown Error" 89 | } 90 | errorHandler(w, 500, errorDescription) 91 | 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func init() { 14 | *errorEmail = "support@mycompany.com" 15 | } 16 | 17 | type testIntString struct { 18 | code int 19 | text string 20 | } 21 | 22 | func TestErrorQuery(t *testing.T) { 23 | for errorQuery, expectedValues := range map[string]testIntString{ 24 | "invalid_request": {400, "400 - Bad Request"}, 25 | "access_denied": {403, "403 - Forbidden"}, 26 | "invalid_resource": {404, "404 - Not Found"}, 27 | "unknown": {500, "500 - Internal Server Error"}, 28 | "server_error": {500, "500 - Internal Server Error"}, 29 | "unsupported_response_type": {501, "501 - Not Implemented"}, 30 | "temporarily_unavailable": {503, "503 - Service Unavailable"}, 31 | } { 32 | request := httptest.NewRequest("GET", "/oidc?error="+errorQuery, nil) 33 | request.Host = *host 34 | w := httptest.NewRecorder() 35 | testMux.ServeHTTP(w, request) 36 | 37 | resp := w.Result() 38 | body, _ := io.ReadAll(resp.Body) 39 | assert.Equal(t, expectedValues.code, resp.StatusCode) 40 | assert.Contains(t, string(body), expectedValues.text) 41 | } 42 | } 43 | 44 | func TestErrorPlain(t *testing.T) { 45 | *errorPlain = true 46 | 47 | request := httptest.NewRequest("GET", "/oidc?error=server_error&error_description=Foo+Biz", nil) 48 | request.Host = *host 49 | w := httptest.NewRecorder() 50 | testMux.ServeHTTP(w, request) 51 | 52 | resp := w.Result() 53 | body, _ := io.ReadAll(resp.Body) 54 | assert.Equal(t, 500, resp.StatusCode) 55 | assert.Contains(t, string(body), "Foo Biz") 56 | 57 | *errorPlain = false 58 | } 59 | 60 | type testResponseWriter struct { 61 | http.ResponseWriter 62 | } 63 | 64 | func (w *testResponseWriter) WriteHeader(code int) {} 65 | func (w *testResponseWriter) Header() http.Header { return http.Header{} } 66 | func (w *testResponseWriter) Write(data []byte) (n int, err error) { 67 | return 0, fmt.Errorf("WriteError") 68 | } 69 | 70 | func TestErrorExecuteWriteError(t *testing.T) { 71 | w := &testResponseWriter{} 72 | err := errorExecute(w, 500, "WriteError") 73 | assert.Equal(t, "WriteError", err.Error()) 74 | errorHandler(w, 500, "WriteError") 75 | } 76 | -------------------------------------------------------------------------------- /example/Makefile: -------------------------------------------------------------------------------- 1 | myservice.crt: 2 | openssl req -x509 -newkey rsa:2048 -keyout myservice.key -out myservice.cert -days 10000 -nodes -subj "/CN=myservice.example.com" 3 | -------------------------------------------------------------------------------- /example/allowlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": { 3 | "httpbin.org": true, 4 | "echo.websocket.org": true, 5 | "nonexistent.example.test": true 6 | }, 7 | "path": { 8 | "/.well-known/acme-challenge": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/error.json: -------------------------------------------------------------------------------- 1 | { 2 | -------------------------------------------------------------------------------- /example/fence.json: -------------------------------------------------------------------------------- 1 | { 2 | "consultant@gmail.com": [ "git" ], 3 | "vendor@gmail.com": [ "test" ] 4 | } 5 | -------------------------------------------------------------------------------- /example/hosts.json: -------------------------------------------------------------------------------- 1 | { 2 | "old-api.example.com": "new-api.example.com", 3 | "legacy.mycompany.net": "modern.mycompany.net", 4 | "internal.corp": "internal.corp.example.com" 5 | } -------------------------------------------------------------------------------- /example/myservice.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDEzCCAfugAwIBAgIJAJfGe8TGQEfMMA0GCSqGSIb3DQEBCwUAMCAxHjAcBgNV 3 | BAMMFW15c2VydmljZS5leGFtcGxlLmNvbTAeFw0yMTEwMTEwMzI1MTNaFw00OTAy 4 | MjYwMzI1MTNaMCAxHjAcBgNVBAMMFW15c2VydmljZS5leGFtcGxlLmNvbTCCASIw 5 | DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM4rXhFWIeGSF6bPrzi4oY5txAqG 6 | cetoCg8HKYsefoBXgr4gLLWmXDyU/Lbz6/VuIhCw3R8YOwhI5hyLBHv9pXAbk/ec 7 | abVQZ5S8OAuKjfUvhcAKz9sPC6OZQaW3iB9ugDpU4NXdogLG0uDsNQf5HMRihduJ 8 | ko9j3pn0er64XL4Ih/XwSrBs6f5CRs/L6+c1pxRvk+zKgSCG9CY1g2W0Qafj62mk 9 | OyJO0q2Suyeuy4+iQvBVIQSEtDQ8dDnXhy7g+svmCZ0NdKRSdwXkReEvbGZCh+jX 10 | WmV2zZZtRlqeDF1acy7lyq2AQNHDXFpugjskjvXeSf2VtjSsfjTfWLDKfVUCAwEA 11 | AaNQME4wHQYDVR0OBBYEFKNf8T3k0XwtE60CgOrxy8R2doIQMB8GA1UdIwQYMBaA 12 | FKNf8T3k0XwtE60CgOrxy8R2doIQMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEL 13 | BQADggEBADi78eqKytPMQWlNUjylQa21duPRK1bI6Se84ge9n3+Cvq6PFqLQmXV2 14 | iKG4Noewxw99S8PCJ0OV0MdpP/CaptJAQ1Dk4MvHwc6mvdeh/R3IFQlLhTM6yLZO 15 | EpbzNg7hFxTi9q1E8oYlUGMyYGw5GI5qs0hR3EHJB/tTYLM1dwGIn9B3cEQanG2x 16 | NI5jm0Tip5mhAkSnXnJAEfMuGl82qz5dSDLZ5TcOADE2UNCs5ScuiGe+uP/4Cf9s 17 | Z/cI4lTJykk54G1fFA5A5UutSNbB33nwNJTLQmL8VXyVES7+5J787D+eUuxb2A4N 18 | /TpH0swHtGNox0nyzWEWLRLYssRlrho= 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /example/myservice.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDOK14RViHhkhem 3 | z684uKGObcQKhnHraAoPBymLHn6AV4K+ICy1plw8lPy28+v1biIQsN0fGDsISOYc 4 | iwR7/aVwG5P3nGm1UGeUvDgLio31L4XACs/bDwujmUGlt4gfboA6VODV3aICxtLg 5 | 7DUH+RzEYoXbiZKPY96Z9Hq+uFy+CIf18EqwbOn+QkbPy+vnNacUb5PsyoEghvQm 6 | NYNltEGn4+tppDsiTtKtkrsnrsuPokLwVSEEhLQ0PHQ514cu4PrL5gmdDXSkUncF 7 | 5EXhL2xmQofo11plds2WbUZangxdWnMu5cqtgEDRw1xaboI7JI713kn9lbY0rH40 8 | 31iwyn1VAgMBAAECggEBALiXj7PKAK/slAyg6uvIM65IjTw3QAxbrJXj/cg311+l 9 | +sOsHxvNBcygQNy17NBkeU+ka3cJxOEYFB/+QFebtOJ5brRGcUcL5JIBK9T5izy5 10 | /fECPTSPdgZWk7aCavhpgZm4oU4bEJGX3vvPwXOQEber1tnHhVepPYduo+/bNf5W 11 | HP82M5nfte+F81UPCKc8FRFGtf8dCcG/iUv4YENkVXZ7Gj0gaIav21k/t+YaEQji 12 | K7/5yoEbX5krKZ3EGetwai+ET+2241odl1TXgtVDqEw2Cddar47khY9ScHjN631f 13 | IvvoAYgbzASxihgM2Sm7wNzZ0y0SkIsFPTEuYS/IXYECgYEA83kXYCg1WehXVS9z 14 | yzj9U2+jO2hmaL3YmIcV4wkkBDaYRxx3QmTi5Cq0KHjQJS42fC4q+IwDhX9TDFd7 15 | QuGa7LuMwvNtApKLpPocWX2ls87z5Q0yEVCgIABWZwwwAP6Q2zxvavXtTq0gMM78 16 | KxXcKqyLILWMAqEsu0ycGYWN+hECgYEA2MbuWfLj7c1AZgVyLvsJcV2rD5JvRxTV 17 | oJCJKU+IlX0o6SvpMVsBCMvskpIIJNPeiFXAUsb098kU/JchL+365PU6OV1cjk0S 18 | VYnqE6aMYCPtYWsyW1ZQpq0iNS2ceNO+FTu31fCHYkg/mVcvW4X5yf96Kh15wdOt 19 | PPn1xUoH6wUCgYEApxDOU3MDuN/PHWrkP+ypF5mhHmCy9OhgObln8VQSXLnBn/oO 20 | c4aakgojeSn9WaXuSSO85LPerroBmJRmxivcjNjc6+DxSjSYken1jgrDqzA091Uo 21 | p+z1E0BoCWm5ftUvPOCpa3G5FqWBUzyUmFP1sWqYRSeOHTfPY+5bpr+X/qECgYEA 22 | ly9ujocq1e//SOflccSdGlaUdiDPwQhT0U7cilYw0OlgffalBUoN15+5l6OHUH// 23 | RKBhqJmfwayZGW0htTbJc6Nf/yAQ7CHudn/gI+JVJrwH05ianz78srIvGF1+Mnqi 24 | qFZk6S1+jloLGRvIKJGw22N1RSgXgcnqmYtPEFCIYy0CgYEA83NbO5/0AJIBHAyu 25 | XXypDMdxOIiewOblYVM7uVqrCS5oPwU0lUZU7PfbsLjnam0bqKV4N78+HUer998A 26 | i6gblz/UeIpGE4w/erzT2h5aq4MVyz0/XcBdXOIRMCftzXQvLS3YkPHrro3RhDoV 27 | iSdayC6u+ElVnS2daWwb92oGhlg= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /example/sites.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": [ 3 | "https://github.com", 4 | "https://lab.colofoo.net", 5 | "https://zenhub.colofoo.net", 6 | "https://coveralls.colofoo.net", 7 | "https://docker.colofoo.net", 8 | "https://travis.colofoo.net", 9 | "https://godoc.colofoo.net" 10 | ], 11 | "git": [ 12 | "https://github.com", 13 | "https://assets.github.com", 14 | "https://avatars.github.com", 15 | "https://codeload.github.com", 16 | "https://gist.github.com", 17 | "https://gist-assets.github.com", 18 | "https://gist-raw.github.com", 19 | "https://media.github.com", 20 | "https://pages.github.com", 21 | "https://raw.github.com", 22 | "https://render.github.com", 23 | "https://reply.github.com", 24 | "https://uploads.github.com" 25 | ], 26 | "logs": [ 27 | "https://grafana.colofoo.net", 28 | "https://logstash.colofoo.net" 29 | ], 30 | "sentry": [ 31 | "http://sentry.colofoo.net", 32 | "http://sentry7.colofoo.net", 33 | "https://sentry8.colofoo.net" 34 | ], 35 | "test": [ 36 | "http://httpbin.org", 37 | "https://httpbin.org", 38 | "http://test.websocket.org" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /federate.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "net/http" 7 | 8 | "github.com/gorilla/securecookie" 9 | ) 10 | 11 | var ( 12 | federateAccessKey = flag.String("federate-access", "", "shared secret, 64 chars, enables federation") 13 | federateSecretKey = flag.String("federate-secret", "", "internal secret, 64 chars") 14 | 15 | federateAccessCodec []securecookie.Codec 16 | federateSecretCodec []securecookie.Codec 17 | ) 18 | 19 | func federateSetup() error { 20 | if *federateAccessKey == "" { 21 | return nil 22 | } 23 | 24 | federateAccessCodec = securecookie.CodecsFromPairs([]byte(*federateAccessKey)[0:31], []byte(*federateAccessKey)[32:64]) 25 | federateSecretCodec = securecookie.CodecsFromPairs([]byte(*federateSecretKey)[0:31], []byte(*federateSecretKey)[32:64]) 26 | return nil 27 | } 28 | 29 | func federate(w http.ResponseWriter, r *http.Request) { 30 | setCacheControl(w) 31 | 32 | // authenticate relying party 33 | next := r.URL.Query().Get("next") 34 | err := securecookie.DecodeMulti("next", next, &next, federateAccessCodec...) 35 | if err != nil { 36 | http.Error(w, err.Error(), http.StatusForbidden) 37 | return 38 | } 39 | 40 | // authenticate end user 41 | session, err := store.Get(r, *cookieName) 42 | if err != nil { 43 | session = store.New(*cookieName) 44 | } 45 | user, _ := session.Values["user"].(string) 46 | 47 | // 401 48 | if user == "" { 49 | login(w, r) 50 | return 51 | } 52 | 53 | // issue token 54 | token, err := securecookie.EncodeMulti("user", user, federateSecretCodec...) 55 | if err != nil { 56 | http.Error(w, err.Error(), 500) 57 | return 58 | } 59 | 60 | // 302 61 | http.Redirect(w, r, next+token, http.StatusFound) 62 | } 63 | 64 | func federateVerify(w http.ResponseWriter, r *http.Request) { 65 | // authenticate relying party 66 | token := r.URL.Query().Get("token") 67 | err := securecookie.DecodeMulti("user", token, &token, federateSecretCodec...) 68 | if err != nil { 69 | http.Error(w, err.Error(), 500) 70 | return 71 | } 72 | 73 | v := map[string]string{"email": token} 74 | json.NewEncoder(w).Encode(v) 75 | } 76 | -------------------------------------------------------------------------------- /federate_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/gorilla/securecookie" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var ( 15 | federateServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | switch r.URL.Path { 17 | case "/next": 18 | token := r.URL.Query().Get("token") 19 | if token == "" { 20 | w.WriteHeader(551) 21 | return 22 | } 23 | 24 | // Make request to testMux directly 25 | request := httptest.NewRequest("GET", "/federate/verify?token="+token, nil) 26 | request.Host = *host 27 | recorder := httptest.NewRecorder() 28 | testMux.ServeHTTP(recorder, request) 29 | 30 | resp := recorder.Result() 31 | body, _ := io.ReadAll(resp.Body) 32 | w.WriteHeader(resp.StatusCode) 33 | w.Write(body) 34 | return 35 | 36 | default: 37 | return 38 | 39 | } 40 | })) 41 | ) 42 | 43 | func TestFederateSetup(t *testing.T) { 44 | assert.NoError(t, federateSetup()) 45 | assert.Empty(t, federateAccessCodec) 46 | 47 | *federateAccessKey = "9zcNzr9ObeWnNExMXYbeXxy9CxMMz6FS6ZhSfYRwzXHTNa3ZJo7uFQ2qsWZ5u1Id" 48 | *federateSecretKey = "S6ZhSfYRwzXHTNa3ZJo7uFQ2qsWZ5u1Id9zcNzr9ObeWnNExMXYbeXxy9CxMMz6F" 49 | assert.NoError(t, federateSetup()) 50 | assert.NotEmpty(t, federateAccessCodec) 51 | assert.NotEmpty(t, federateSecretCodec) 52 | } 53 | 54 | func TestFederateHandler(t *testing.T) { 55 | // Test federate endpoint without next parameter 56 | request := httptest.NewRequest("GET", "/federate", nil) 57 | request.Host = *host 58 | w := httptest.NewRecorder() 59 | testMux.ServeHTTP(w, request) 60 | 61 | resp := w.Result() 62 | body, _ := io.ReadAll(resp.Body) 63 | assert.Equal(t, 403, resp.StatusCode) 64 | assert.Equal(t, "securecookie: the value is not valid\n", string(body)) 65 | 66 | // Test federate endpoint with encoded next parameter (no auth) 67 | next := federateServer.URL + "/next?token=" 68 | next, err := securecookie.EncodeMulti("next", next, federateAccessCodec...) 69 | assert.NoError(t, err) 70 | 71 | request = httptest.NewRequest("GET", "/federate?next="+url.QueryEscape(next), nil) 72 | request.Host = *host 73 | w = httptest.NewRecorder() 74 | testMux.ServeHTTP(w, request) 75 | 76 | resp = w.Result() 77 | body, _ = io.ReadAll(resp.Body) 78 | assert.Equal(t, *fouroOneCode, resp.StatusCode) 79 | assert.Contains(t, string(body), "/launch?next=https") 80 | 81 | // Test federate endpoint with auth cookie - should redirect to federate server 82 | request = httptest.NewRequest("GET", "/federate?next="+url.QueryEscape(next), nil) 83 | request.Host = *host 84 | vals := map[string]interface{}{"user": "cloud@user.com"} 85 | cookieValue, err := securecookie.EncodeMulti(*cookieName, &vals, store.Codecs...) 86 | assert.NoError(t, err) 87 | request.AddCookie(&http.Cookie{Name: *cookieName, Value: cookieValue}) 88 | w = httptest.NewRecorder() 89 | testMux.ServeHTTP(w, request) 90 | 91 | resp = w.Result() 92 | body, _ = io.ReadAll(resp.Body) 93 | // The federate handler redirects to the federate server, so we expect a 302 94 | assert.Equal(t, 302, resp.StatusCode) 95 | assert.Contains(t, string(body), "Found") 96 | 97 | // Test with broken secret codec 98 | federateSecretCodec = []securecookie.Codec{} 99 | request = httptest.NewRequest("GET", "/federate?next="+url.QueryEscape(next), nil) 100 | request.Host = *host 101 | request.AddCookie(&http.Cookie{Name: *cookieName, Value: cookieValue}) 102 | w = httptest.NewRecorder() 103 | testMux.ServeHTTP(w, request) 104 | 105 | resp = w.Result() 106 | body, _ = io.ReadAll(resp.Body) 107 | assert.Equal(t, 500, resp.StatusCode) 108 | assert.Contains(t, string(body), "securecookie: no codecs provided") 109 | } 110 | 111 | func TestFederateVerify500(t *testing.T) { 112 | req := httptest.NewRequest("GET", "http://"+*host+"/federate/verify?", nil) 113 | w := httptest.NewRecorder() 114 | testMux.ServeHTTP(w, req) 115 | 116 | resp := w.Result() 117 | body, _ := io.ReadAll(resp.Body) 118 | 119 | assert.Equal(t, 500, resp.StatusCode) 120 | assert.Equal(t, "securecookie: no codecs provided\n", string(body)) 121 | } 122 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/presbrey/beyond 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/cogolabs/wait v0.0.0-20200531154825-18b10c34d00e 9 | github.com/coreos/go-oidc v2.4.0+incompatible 10 | github.com/crewjam/saml v0.4.14 11 | github.com/dghubble/sessions v0.1.1-0.20190708004734-43a1b0057682 12 | github.com/google/uuid v1.6.0 13 | github.com/gorilla/securecookie v1.1.2-0.20191028042304-61b4ad17eb88 14 | github.com/gorilla/websocket v1.4.2 15 | github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c 16 | github.com/olivere/elastic v6.2.17-0.20190330134010-0eef387a17cd+incompatible 17 | github.com/patrickmn/go-cache v2.1.0+incompatible 18 | github.com/pkg/errors v0.9.1 19 | github.com/russellhaering/goxmldsig v1.3.0 20 | github.com/sirupsen/logrus v1.7.0 21 | github.com/stretchr/testify v1.8.1 22 | golang.org/x/oauth2 v0.30.0 23 | inet.af/tcpproxy v0.0.0-20220326234310-be3ee21c9fa0 24 | ) 25 | 26 | require ( 27 | github.com/beevik/etree v1.1.1-0.20200718192613-4a2f8b9d084c // indirect 28 | github.com/crewjam/httperr v0.2.0 // indirect 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/fortytw2/leaktest v1.3.0 // indirect 31 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 32 | github.com/jonboulle/clockwork v0.2.2 // indirect 33 | github.com/mailru/easyjson v0.7.2-0.20200312161126-f5d75e7e500c // indirect 34 | github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect 35 | github.com/pmezard/go-difflib v1.0.0 // indirect 36 | github.com/pquerna/cachecontrol v0.2.0 // indirect 37 | golang.org/x/crypto v0.41.0 // indirect 38 | golang.org/x/sys v0.35.0 // indirect 39 | gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect 40 | gopkg.in/yaml.v3 v3.0.1 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= 2 | github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= 3 | github.com/beevik/etree v1.1.1-0.20200718192613-4a2f8b9d084c h1:uYq6BD31fkfeNKQmfLj7ODcEfkb5JLsKrXVSqgnfGg8= 4 | github.com/beevik/etree v1.1.1-0.20200718192613-4a2f8b9d084c/go.mod h1:0yGO2rna3S9DkITDWHY1bMtcY4IJ4w+4S+EooZUR0bE= 5 | github.com/cogolabs/wait v0.0.0-20200531154825-18b10c34d00e h1:4N9uLXmA9hiyOC5hOCHovrMJOFWHPYfpf6ToYgIWvh8= 6 | github.com/cogolabs/wait v0.0.0-20200531154825-18b10c34d00e/go.mod h1:i07p4hynptc2mQFfBQdZ66km5Dq2LVW+QIcxpFFN/Xo= 7 | github.com/coreos/go-oidc v2.4.0+incompatible h1:xjdlhLWXcINyUJgLQ9I76g7osgC2goiL6JDXS6Fegjk= 8 | github.com/coreos/go-oidc v2.4.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= 11 | github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= 12 | github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= 13 | github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/dghubble/sessions v0.1.1-0.20190708004734-43a1b0057682 h1:wbI2kJBKzkaX8Cn4/Ggdbjj7aj8X8UVByNHV9upTePw= 18 | github.com/dghubble/sessions v0.1.1-0.20190708004734-43a1b0057682/go.mod h1:zrBDnKg9yMmEOAne3zuiJRW5jE5koGaRBnRKsS41LDc= 19 | github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= 20 | github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= 21 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 22 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 23 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 24 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 25 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 26 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 27 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 28 | github.com/gorilla/securecookie v1.1.2-0.20191028042304-61b4ad17eb88 h1:q9o4FgidIqebyn5E7ajWOfyCucOABH0eCx6Fp3hSjo4= 29 | github.com/gorilla/securecookie v1.1.2-0.20191028042304-61b4ad17eb88/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 30 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 31 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 32 | github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= 33 | github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= 34 | github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw= 35 | github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA= 36 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 37 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 38 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 42 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 43 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 44 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 45 | github.com/mailru/easyjson v0.7.2-0.20200312161126-f5d75e7e500c h1:xLvMOVhQQ1UxAffHSzkleO7udNqb34Ig/ELSCab4YYM= 46 | github.com/mailru/easyjson v0.7.2-0.20200312161126-f5d75e7e500c/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= 47 | github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= 48 | github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= 49 | github.com/olivere/elastic v6.2.17-0.20190330134010-0eef387a17cd+incompatible h1:UI5d8nD/ar73o+tayKpH0XvQNrK9EuDWt7ENbbAYmJA= 50 | github.com/olivere/elastic v6.2.17-0.20190330134010-0eef387a17cd+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= 51 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 52 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 53 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 54 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 55 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 56 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 | github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= 60 | github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= 61 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 62 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 63 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 64 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 65 | github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= 66 | github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= 67 | github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= 68 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 69 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 71 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 72 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 73 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 74 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 75 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 76 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 77 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 78 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 79 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 80 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 81 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 82 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 83 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 85 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 86 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 87 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 89 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 90 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 91 | gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= 92 | gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= 93 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 94 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 96 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 97 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 98 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 99 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 100 | inet.af/tcpproxy v0.0.0-20220326234310-be3ee21c9fa0 h1:PqdHrvQRVK1zapJkd0qf6+tevvSIcWdfenVqJd3PHWU= 101 | inet.af/tcpproxy v0.0.0-20220326234310-be3ee21c9fa0/go.mod h1:Tojt5kmHpDIR2jMojxzZK2w2ZR7OILODmUo2gaSwjrk= 102 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | func handleLaunch(w http.ResponseWriter, r *http.Request) { 13 | setCacheControl(w) 14 | session, err := store.Get(r, *cookieName) 15 | if err != nil { 16 | session = store.New(*cookieName) 17 | } 18 | if samlSP != nil && samlFilter(w, r) { 19 | next, _ := session.Values["next"].(string) 20 | jsRedirect(w, next) 21 | return 22 | } 23 | 24 | session.Values["next"] = r.URL.Query().Get("next") 25 | state, _ := randhex32() 26 | session.Values["state"] = state 27 | session.Save(w) 28 | 29 | if *samlIDP == "" { 30 | next := oidcConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) 31 | jsRedirect(w, next) 32 | } else { 33 | samlSP.HandleStartAuthFlow(w, r) 34 | } 35 | } 36 | 37 | func handleOIDC(w http.ResponseWriter, r *http.Request) { 38 | setCacheControl(w) 39 | 40 | if r.URL.Query().Get("error") != "" { 41 | errorQuery(w, r) 42 | return 43 | } 44 | 45 | session, err := store.Get(r, *cookieName) 46 | if err != nil { 47 | errorHandler(w, 400, err.Error()) 48 | return 49 | } 50 | if state, ok := session.Values["state"].(string); !ok || state != r.URL.Query().Get("state") { 51 | errorHandler(w, 403, "Invalid Browser State") 52 | return 53 | } 54 | email, err := oidcVerify(r.URL.Query().Get("code")) 55 | if err != nil { 56 | errorHandler(w, 401, err.Error()) 57 | return 58 | } 59 | session.Values["user"] = email 60 | next, _ := session.Values["next"].(string) 61 | session.Values["next"] = "" 62 | session.Values["state"] = "" 63 | session.Save(w) 64 | 65 | http.Redirect(w, r, next, http.StatusFound) 66 | } 67 | 68 | func handler(w http.ResponseWriter, r *http.Request) { 69 | // check for cookie authentication 70 | session, err := store.Get(r, *cookieName) 71 | if err != nil { 72 | session = store.New(*cookieName) 73 | } 74 | user, _ := session.Values["user"].(string) 75 | 76 | // check for oauth2 token 77 | if user == "" { 78 | user = tokenAuth(r) 79 | } 80 | if user != "" { 81 | r.Header.Set(*headerPrefix+"-User", user) 82 | } 83 | 84 | // apply allowlist 85 | if allowlisted(r) { 86 | nexthop(w, r) 87 | return 88 | } 89 | 90 | // check host-only restriction 91 | if !hostAllowed(r.Host) { 92 | errorHandler(w, 403, "Host not allowed") 93 | return 94 | } 95 | 96 | // force login 97 | if user == "" { 98 | login(w, r) 99 | return 100 | } 101 | 102 | // apply fence 103 | if deny(r, user) { 104 | errorHandler(w, 403, "Access Denied") 105 | return 106 | } 107 | 108 | // allow 109 | nexthop(w, r) 110 | } 111 | 112 | func login(w http.ResponseWriter, r *http.Request) { 113 | if w == nil { 114 | return 115 | } 116 | 117 | setCacheControl(w) 118 | w.WriteHeader(*fouroOneCode) 119 | 120 | // short-circuit WS+AJAX 121 | if r.Header.Get("Upgrade") != "" || r.Header.Get("X-Requested-With") != "" { 122 | return 123 | } 124 | 125 | jsRedirect(w, "https://"+*host+"/launch?next="+url.QueryEscape("https://"+r.Host+r.RequestURI)) 126 | } 127 | 128 | func jsRedirect(w http.ResponseWriter, next string) { 129 | if w == nil { 130 | return 131 | } 132 | 133 | // hack to guarantee interactive session 134 | w.Header().Set("Content-Type", "text/html") 135 | fmt.Fprintf(w, ` 136 | 139 | `, next) 140 | } 141 | 142 | func setCacheControl(w http.ResponseWriter) { 143 | if w == nil { 144 | return 145 | } 146 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 147 | } 148 | 149 | func randhex32() (string, error) { 150 | b := make([]byte, 32) 151 | _, err := rand.Read(b) 152 | return fmt.Sprintf("%x", b), err 153 | } 154 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/gorilla/websocket" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func init() { 16 | // Load the allowlist for testing 17 | cwd, _ := os.Getwd() 18 | *allowlistURL = "file://" + cwd + "/example/allowlist.json" 19 | refreshAllowlist() 20 | } 21 | 22 | func TestHandlerPing(t *testing.T) { 23 | request := httptest.NewRequest("GET", *healthPath, nil) 24 | w := httptest.NewRecorder() 25 | testMux.ServeHTTP(w, request) 26 | 27 | resp := w.Result() 28 | body, _ := io.ReadAll(resp.Body) 29 | assert.Equal(t, 200, resp.StatusCode) 30 | assert.Equal(t, *healthReply, string(body)) 31 | } 32 | 33 | func TestHandlerGo(t *testing.T) { 34 | request := httptest.NewRequest("GET", "/test?a=1", nil) 35 | request.Host = "github.com" 36 | w := httptest.NewRecorder() 37 | testMux.ServeHTTP(w, request) 38 | 39 | resp := w.Result() 40 | body, _ := io.ReadAll(resp.Body) 41 | assert.Equal(t, *fouroOneCode, resp.StatusCode) 42 | assert.Equal(t, "", resp.Header.Get("Set-Cookie")) 43 | assert.Equal(t, "\n\n", string(body)) 44 | } 45 | 46 | func TestHandlerLaunch(t *testing.T) { 47 | request := httptest.NewRequest("GET", "/launch?next=https%3A%2F%2Falachart.colofoo.net%2Ftest%3Fa%3D1", nil) 48 | request.Host = *host 49 | w := httptest.NewRecorder() 50 | testMux.ServeHTTP(w, request) 51 | 52 | resp := w.Result() 53 | assert.Equal(t, 200, resp.StatusCode) 54 | assert.NotEqual(t, "", resp.Header.Get("Set-Cookie")) 55 | } 56 | 57 | func TestHandlerOidcNoCookie(t *testing.T) { 58 | request := httptest.NewRequest("GET", "/oidc", nil) 59 | request.Host = *host 60 | w := httptest.NewRecorder() 61 | testMux.ServeHTTP(w, request) 62 | 63 | resp := w.Result() 64 | assert.Equal(t, 400, resp.StatusCode) 65 | } 66 | 67 | func TestHandlerOidcStateInvalid(t *testing.T) { 68 | session := store.New(*cookieName) 69 | recorder := httptest.NewRecorder() 70 | assert.NoError(t, store.Save(recorder, session)) 71 | cookie := strings.Split(recorder.Header().Get("Set-Cookie"), ";")[0] 72 | 73 | request := httptest.NewRequest("GET", "/oidc?state=test1", nil) 74 | request.Host = *host 75 | request.Header.Set("Cookie", cookie) 76 | w := httptest.NewRecorder() 77 | testMux.ServeHTTP(w, request) 78 | 79 | resp := w.Result() 80 | body, _ := io.ReadAll(resp.Body) 81 | assert.Equal(t, 403, resp.StatusCode) 82 | assert.Contains(t, string(body), "Invalid Browser State") 83 | } 84 | 85 | func TestHandlerOidcStateValid(t *testing.T) { 86 | session := store.New(*cookieName) 87 | session.Values["state"] = "test1" 88 | recorder := httptest.NewRecorder() 89 | assert.NoError(t, store.Save(recorder, session)) 90 | cookie := strings.Split(recorder.Header().Get("Set-Cookie"), ";")[0] 91 | 92 | request := httptest.NewRequest("GET", "/oidc?state=test1", nil) 93 | request.Host = *host 94 | request.Header.Set("Cookie", cookie) 95 | w := httptest.NewRecorder() 96 | testMux.ServeHTTP(w, request) 97 | 98 | resp := w.Result() 99 | body, _ := io.ReadAll(resp.Body) 100 | assert.Equal(t, 401, resp.StatusCode) 101 | assert.Contains(t, string(body), "oauth2:") 102 | } 103 | 104 | func TestHandlerWebsocket(t *testing.T) { 105 | t.SkipNow() 106 | 107 | server := httptest.NewServer(testMux) 108 | x, y, err := websocket.DefaultDialer.Dial(strings.Replace(server.URL, "http://", "ws://", 1)+"/", http.Header{"Host": []string{"echo.websocket.org"}}) 109 | assert.NoError(t, err) 110 | err = x.WriteMessage(websocket.TextMessage, []byte("BEYOND")) 111 | assert.NoError(t, err) 112 | 113 | typ, msg, err := x.ReadMessage() 114 | assert.Equal(t, 101, y.StatusCode) 115 | assert.Equal(t, websocket.TextMessage, typ) 116 | assert.Equal(t, "BEYOND", string(msg)) 117 | assert.NoError(t, err) 118 | server.Close() 119 | } 120 | 121 | func TestHandlerAllowlist(t *testing.T) { 122 | // Test allowed host (httpbin.org) 123 | request := httptest.NewRequest("GET", "/", nil) 124 | request.Host = "httpbin.org" 125 | w := httptest.NewRecorder() 126 | testMux.ServeHTTP(w, request) 127 | 128 | resp := w.Result() 129 | body, _ := io.ReadAll(resp.Body) 130 | assert.Equal(t, 200, resp.StatusCode) 131 | assert.Equal(t, "", resp.Header.Get("Set-Cookie")) 132 | assert.Contains(t, string(body), "httpbin.org") 133 | 134 | // Test blocked host (github.com) 135 | request = httptest.NewRequest("GET", "/.well-known/acme-challenge/test", nil) 136 | request.Host = "github.com" 137 | w = httptest.NewRecorder() 138 | testMux.ServeHTTP(w, request) 139 | 140 | resp = w.Result() 141 | body, _ = io.ReadAll(resp.Body) 142 | assert.Equal(t, 404, resp.StatusCode) 143 | assert.NotEqual(t, "", resp.Header.Get("Set-Cookie")) 144 | assert.Contains(t, string(body), "Page not found") 145 | } 146 | 147 | func TestHandlerXHR(t *testing.T) { 148 | request := httptest.NewRequest("GET", "/test?a=1", nil) 149 | request.Host = "github.com" 150 | request.Header.Set("X-Requested-With", "XMLHttpRequest") 151 | w := httptest.NewRecorder() 152 | testMux.ServeHTTP(w, request) 153 | 154 | resp := w.Result() 155 | body, _ := io.ReadAll(resp.Body) 156 | assert.Equal(t, *fouroOneCode, resp.StatusCode) 157 | assert.Equal(t, "", resp.Header.Get("Set-Cookie")) 158 | assert.Equal(t, "", string(body)) 159 | } 160 | 161 | func TestNexthopInvalid(t *testing.T) { 162 | request := httptest.NewRequest("GET", "/favicon.ico", nil) 163 | request.Host = "nonexistent.example.test" 164 | w := httptest.NewRecorder() 165 | testMux.ServeHTTP(w, request) 166 | 167 | resp := w.Result() 168 | body, _ := io.ReadAll(resp.Body) 169 | assert.Equal(t, 404, resp.StatusCode) 170 | assert.Equal(t, "", resp.Header.Get("Set-Cookie")) 171 | assert.Contains(t, string(body), *fouroFourMessage) 172 | } 173 | 174 | func TestRandhex32(t *testing.T) { 175 | h, err := randhex32() 176 | assert.Len(t, h, 64) 177 | assert.NoError(t, err) 178 | } 179 | -------------------------------------------------------------------------------- /learn.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var ( 14 | learnNexthops = flag.Bool("learn-nexthops", true, "set false to require explicit allowlisting") 15 | 16 | learnHTTPSPorts = flag.String("learn-https-ports", "443,4443,6443,8443,9443,9090", "try learning these backend HTTPS ports (csv)") 17 | learnHTTPPorts = flag.String("learn-http-ports", "80,8080,6000,6060,7000,7070,8000,9000,9200,15672", "after HTTPS, try these HTTP ports (csv)") 18 | 19 | learnDialTimeout = flag.Duration("learn-dial-timeout", 8*time.Second, "skip port after this connection timeout") 20 | ) 21 | 22 | func learn(host string) http.Handler { 23 | newBase := learnBase(host) 24 | if newBase != "" { 25 | u, err := url.Parse(newBase) 26 | if err == nil { 27 | return newSHRP(u) 28 | } 29 | } 30 | return nil 31 | } 32 | 33 | func learnBase(host string) string { 34 | if strings.Contains(host, ":") { 35 | if strings.HasPrefix(host, "https:") || strings.HasPrefix(host, "http:") { 36 | return host 37 | } 38 | c, err := tls.DialWithDialer(&net.Dialer{Timeout: *learnDialTimeout}, "tcp", host, tlsConfig) 39 | if err == nil { 40 | c.Close() 41 | return "https://" + host 42 | } 43 | return "http://" + host 44 | } 45 | for _, httpsPort := range strings.Split(*learnHTTPSPorts, ",") { 46 | c, err := tls.DialWithDialer(&net.Dialer{Timeout: *learnDialTimeout}, "tcp", host+":"+httpsPort, tlsConfig) 47 | if err == nil { 48 | c.Close() 49 | if httpsPort == "443" { 50 | return "https://" + host 51 | } 52 | return "https://" + host + ":" + httpsPort 53 | } 54 | } 55 | for _, httpPort := range strings.Split(*learnHTTPPorts, ",") { 56 | c, err := net.DialTimeout("tcp", host+":"+httpPort, *learnDialTimeout) 57 | if err == nil { 58 | c.Close() 59 | if httpPort == "80" { 60 | return "http://" + host 61 | } 62 | return "http://" + host + ":" + httpPort 63 | } 64 | } 65 | return "" 66 | } 67 | -------------------------------------------------------------------------------- /learn_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "inet.af/tcpproxy" 11 | ) 12 | 13 | var ( 14 | proxy tcpproxy.Proxy 15 | ) 16 | 17 | func init() { 18 | proxy.AddRoute("127.0.0.1:9443", tcpproxy.To("1.1.1.1:443")) 19 | } 20 | 21 | func TestLearnProxy(t *testing.T) { 22 | tlsConfig.InsecureSkipVerify = true 23 | assert.NoError(t, proxy.Start()) 24 | } 25 | 26 | func TestLearnHostScheme(t *testing.T) { 27 | // Test that learnBase returns a valid URL scheme for localhost 28 | result := learnBase("localhost") 29 | assert.True(t, strings.HasPrefix(result, "http://") || strings.HasPrefix(result, "https://")) 30 | assert.Contains(t, result, "localhost") 31 | 32 | ports1 := *learnHTTPSPorts 33 | ports2 := *learnHTTPPorts 34 | 35 | learnTest1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 36 | learnTest2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 37 | *learnHTTPSPorts = strings.Split(learnTest1.URL, ":")[2] 38 | *learnHTTPPorts = strings.Split(learnTest2.URL, ":")[2] 39 | base1 := learnBase("127.0.0.1") 40 | learnTest1.Close() 41 | base2 := learnBase("127.0.0.1") 42 | learnTest2.Close() 43 | assert.Equal(t, learnTest2.URL, base1) 44 | assert.Equal(t, learnTest2.URL, base2) 45 | 46 | *learnHTTPSPorts = "" 47 | *learnHTTPPorts = "80" 48 | assert.Equal(t, "http://www.google.com", learnBase("www.google.com")) 49 | 50 | *learnHTTPSPorts = ports1 51 | *learnHTTPPorts = ports2 52 | assert.Equal(t, "https://golang.org", learnBase("golang.org")) 53 | assert.NotNil(t, learn("golang.org")) 54 | 55 | assert.Equal(t, "https://golang.org:443", learnBase("golang.org:443")) 56 | } 57 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | "github.com/olivere/elastic" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | var ( 16 | // import logrus helpers 17 | Error = log.Error 18 | WithError = log.WithError 19 | WithField = log.WithField 20 | WithFields = log.WithFields 21 | 22 | logHTTP = flag.Bool("log-http", false, "enable HTTP logging to stdout") 23 | logJSON = flag.Bool("log-json", false, "use json output (logrus)") 24 | logXFF = flag.Bool("log-xff", true, "include X-Forwarded-For in logs") 25 | 26 | logElastic = flag.String("log-elastic", "", "csv of elasticsearch servers") 27 | logElasticD = flag.Duration("log-elastic-interval", time.Second, "how often to commit bulk updates") 28 | logElasticP = flag.String("log-elastic-prefix", "beyond", "insert this on the front of elastic indexes") 29 | logElasticW = flag.Int("log-elastic-workers", 3, "bulk commit workers") 30 | logElasticCh = make(chan *elastic.BulkUpdateRequest, 10240) 31 | ) 32 | 33 | func logSetup() error { 34 | if *logJSON { 35 | log.SetFormatter(&log.JSONFormatter{}) 36 | } 37 | if *logElastic != "" { 38 | return logElasticSetup(*logElastic) 39 | } 40 | return nil 41 | } 42 | 43 | func logRoundtrip(resp *http.Response) { 44 | if !*logHTTP && *logElastic == "" { 45 | return 46 | } 47 | 48 | d := map[string]interface{}{ 49 | "date": time.Now().Format(time.RFC3339), 50 | "user": resp.Request.Header.Get(*headerPrefix + "-User"), 51 | 52 | "useragent": resp.Request.UserAgent(), 53 | 54 | "method": resp.Request.Method, 55 | "host": resp.Request.Host, 56 | "path": resp.Request.URL.Path, 57 | "query": resp.Request.URL.RawQuery, 58 | "origin": resp.Request.Header.Get("Origin"), 59 | 60 | "code": resp.StatusCode, 61 | "len": resp.ContentLength, 62 | "location": resp.Header.Get("Location"), 63 | "proto": resp.Proto, 64 | "server": resp.Header.Get("Server"), 65 | "type": resp.Header.Get("Content-Type"), 66 | 67 | // "req.header": resp.Request.Header, 68 | // "resp.header": resp.Header, 69 | } 70 | if *logXFF { 71 | d["xff"] = resp.Request.Header.Get("X-Forwarded-For") 72 | } 73 | for k, v := range d { 74 | if v == "" { 75 | delete(d, k) 76 | } 77 | } 78 | 79 | if *logHTTP { 80 | WithFields(d).Info("HTTP") 81 | } 82 | if *logElastic != "" { 83 | id := uuid.Must(uuid.NewV7()).String() 84 | elt := elastic.NewBulkUpdateRequest().Index(*logElasticP + "-" + id[:4]).Id(id).Type("http").Doc(d).DocAsUpsert(true) 85 | logElasticPut(elt, logElasticCh) 86 | } 87 | } 88 | 89 | func logElasticPut(elt *elastic.BulkUpdateRequest, sink chan *elastic.BulkUpdateRequest) { 90 | select { 91 | case sink <- elt: 92 | default: 93 | log.Println("overflow:", elt) 94 | } 95 | } 96 | 97 | func logElasticSetup(elasticURLs string) error { 98 | elasticSearch, err := elastic.NewSimpleClient(elastic.SetURL(strings.Split(elasticURLs, ",")...)) 99 | if err == nil { 100 | for i := 0; i < *logElasticW; i++ { 101 | go func() { 102 | bulk := elasticSearch.Bulk() 103 | logElasticWorker(bulk, *logElasticD) 104 | }() 105 | } 106 | } 107 | return nil 108 | } 109 | 110 | func logElasticWorker(bulk *elastic.BulkService, duration time.Duration) { 111 | tick := time.NewTicker(duration) 112 | for { 113 | select { 114 | case elt := <-logElasticCh: 115 | bulk.Add(elt) 116 | 117 | case <-tick.C: 118 | if bulk.NumberOfActions() < 1 { 119 | continue 120 | } 121 | r, err := bulk.Do(context.Background()) 122 | if err != nil { 123 | log.Println(err) 124 | } 125 | if r == nil { 126 | continue 127 | } 128 | fails := r.Failed() 129 | if len(fails) > 0 { 130 | log.Println(len(fails), fails[0].Error) 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /log_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/olivere/elastic" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | var ( 19 | logElasticTestErrorN = 0 20 | logElasticTestServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | switch r.URL.Path { 22 | case "/_bulk": 23 | rBody, _ := io.ReadAll(r.Body) 24 | switch { 25 | case strings.Contains(string(rBody), "ERROR") && logElasticTestErrorN < 5: 26 | logElasticTestErrorN++ 27 | w.WriteHeader(500) 28 | fmt.Fprint(w, `{`) 29 | case strings.Contains(string(rBody), "FAILED"): 30 | w.WriteHeader(200) 31 | r := &mockElasticBulkUpdateResponse{} 32 | r.Items = append(r.Items, mockElasticBulkUpdateResponseItem{}) 33 | r.Items[0].Update.Shards.Failed = 10 34 | json.NewEncoder(w).Encode(r) 35 | default: 36 | w.WriteHeader(200) 37 | fmt.Fprint(w, `{}`) 38 | } 39 | default: 40 | w.WriteHeader(204) 41 | log.Printf("ESTEST(%s): %+v\n", r.URL.Path, r) 42 | } 43 | })) 44 | ) 45 | 46 | func init() { 47 | *logJSON = true 48 | *logXFF = true 49 | 50 | // cover nil 51 | *logElastic = "" 52 | logRoundtrip(nil) 53 | logSetup() 54 | 55 | *logElastic = logElasticTestServer.URL 56 | *logElasticD = time.Millisecond 57 | *logElasticW = 1 58 | } 59 | 60 | func TestLogHTTP(t *testing.T) { 61 | *logHTTP = true 62 | 63 | req, err := http.NewRequest("GET", "/log", nil) 64 | assert.NoError(t, err) 65 | resp := &http.Response{Request: req} 66 | logRoundtrip(resp) 67 | } 68 | 69 | func TestLogElasticOverflow(t *testing.T) { 70 | elt := elastic.NewBulkUpdateRequest() 71 | ch := make(chan *elastic.BulkUpdateRequest) 72 | logElasticPut(elt, ch) 73 | } 74 | 75 | func TestLogElasticWorker(t *testing.T) { 76 | time.Sleep(10 * time.Millisecond) 77 | logElasticCh <- elastic.NewBulkUpdateRequest().Index("FAILED") 78 | time.Sleep(10 * time.Millisecond) 79 | logElasticCh <- elastic.NewBulkUpdateRequest().Index("ERROR") 80 | time.Sleep(10 * time.Millisecond) 81 | } 82 | 83 | type mockElasticBulkUpdateResponse struct { 84 | Took int `json:"took"` 85 | Errors bool `json:"errors"` 86 | Items []mockElasticBulkUpdateResponseItem `json:"items"` 87 | } 88 | 89 | type mockElasticBulkUpdateResponseItem struct { 90 | Update struct { 91 | Index string `json:"_index"` 92 | ID string `json:"_id"` 93 | Version int `json:"_version"` 94 | Result string `json:"result"` 95 | Shards struct { 96 | Total int `json:"total"` 97 | Successful int `json:"successful"` 98 | Failed int `json:"failed"` 99 | } `json:"_shards"` 100 | SeqNo int `json:"_seq_no"` 101 | PrimaryTerm int `json:"_primary_term"` 102 | Status int `json:"status"` 103 | } `json:"update"` 104 | } 105 | -------------------------------------------------------------------------------- /masq.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | // HostRewrite contains the rewritten host information 12 | type HostRewrite struct { 13 | Host string 14 | Scheme string // http, https, or empty (preserve original) 15 | Port string // port number or empty 16 | FullURL string // complete rewritten URL if available 17 | } 18 | 19 | var ( 20 | hostsCSV = flag.String("hosts-csv", "", "rewrite nexthop hosts (format: from1=to1,from2=to2)") 21 | hostsURL = flag.String("hosts-url", "", "URL to host mapping config (eg. https://github.com/myorg/beyond-config/main/raw/hosts.json)") 22 | hostsOnly = flag.Bool("hosts-only", false, "only allow requests to hosts in the host mapping") 23 | hostsMap = map[string]string{} 24 | ) 25 | 26 | func hostsSetup(cfg string) error { 27 | if cfg == "" { 28 | return nil 29 | } 30 | for _, line := range strings.Split(cfg, ",") { 31 | elts := strings.Split(line, "=") 32 | if len(elts) < 2 { 33 | return fmt.Errorf("missing equals assignment in: %+v", line) 34 | } 35 | hostsMap[elts[0]] = elts[1] 36 | } 37 | return nil 38 | } 39 | 40 | func refreshHosts() error { 41 | if *hostsURL == "" { 42 | return nil 43 | } 44 | 45 | resp, err := httpACL.Get(*hostsURL) 46 | if err != nil { 47 | return err 48 | } 49 | defer resp.Body.Close() 50 | 51 | var config map[string]string 52 | err = json.NewDecoder(resp.Body).Decode(&config) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | // Merge URL config with command-line config 58 | for k, v := range config { 59 | hostsMap[k] = v 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func hostAllowed(host string) bool { 66 | if !*hostsOnly { 67 | return true 68 | } 69 | 70 | // If hosts-only is enabled, check if host is in the mapping 71 | if len(hostsMap) == 0 { 72 | return false 73 | } 74 | 75 | for k := range hostsMap { 76 | if strings.HasSuffix(host, k) { 77 | return true 78 | } 79 | } 80 | return false 81 | } 82 | 83 | func hostRewriteDetailed(host string) *HostRewrite { 84 | result := &HostRewrite{Host: host} 85 | 86 | if len(hostsMap) == 0 { 87 | return result 88 | } 89 | 90 | for k, v := range hostsMap { 91 | if strings.HasSuffix(host, k) { 92 | // Check if replacement value is a full URL 93 | if strings.Contains(v, "://") { 94 | // Parse the URL to extract components 95 | if parsedURL, err := url.Parse(v); err == nil { 96 | result.Scheme = parsedURL.Scheme 97 | result.Port = parsedURL.Port() 98 | result.FullURL = v 99 | // For subdomain preservation, do string replacement on the hostname part 100 | result.Host = strings.Replace(host, k, parsedURL.Hostname(), -1) 101 | } else { 102 | // Fallback to simple string replacement if URL parsing fails 103 | result.Host = strings.Replace(host, k, v, -1) 104 | } 105 | } else { 106 | // Simple host replacement (backward compatibility) 107 | result.Host = strings.Replace(host, k, v, -1) 108 | } 109 | break 110 | } 111 | } 112 | return result 113 | } 114 | 115 | func hostRewrite(host string) string { 116 | // Backward compatibility - return just the host 117 | return hostRewriteDetailed(host).Host 118 | } 119 | -------------------------------------------------------------------------------- /masq_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func init() { 13 | // Setup file transport for HTTP client 14 | t := &http.Transport{} 15 | t.RegisterProtocol("file", http.NewFileTransport(http.Dir("/"))) 16 | httpACL.Transport = t 17 | } 18 | 19 | func TestHostsCSV(t *testing.T) { 20 | // Reset the map for clean testing 21 | hostsMap = map[string]string{} 22 | 23 | assert.NoError(t, hostsSetup("")) 24 | assert.Equal(t, "test1.com", hostRewrite("test1.com")) 25 | 26 | assert.NoError(t, hostsSetup("test1.com=test1.net,test2.com=test2.org")) 27 | assert.Equal(t, "test1.net", hostRewrite("test1.com")) 28 | assert.Equal(t, "test2.org", hostRewrite("test2.com")) 29 | 30 | assert.Contains(t, hostsSetup("foo").Error(), "missing equals assignment") 31 | } 32 | 33 | func TestHostsURL(t *testing.T) { 34 | // Reset the map for clean testing 35 | hostsMap = map[string]string{} 36 | 37 | // Test with no URL 38 | *hostsURL = "" 39 | assert.NoError(t, refreshHosts()) 40 | assert.Equal(t, "old-api.example.com", hostRewrite("old-api.example.com")) 41 | 42 | // Test with example JSON file 43 | cwd, _ := os.Getwd() 44 | *hostsURL = "file://" + cwd + "/example/hosts.json" 45 | assert.NoError(t, refreshHosts()) 46 | assert.Equal(t, "new-api.example.com", hostRewrite("old-api.example.com")) 47 | assert.Equal(t, "modern.mycompany.net", hostRewrite("legacy.mycompany.net")) 48 | assert.Equal(t, "internal.corp.example.com", hostRewrite("internal.corp")) 49 | 50 | // Test error handling with invalid URL 51 | *hostsURL = "file://" + cwd + "/nonexistent.json" 52 | assert.Error(t, refreshHosts()) 53 | 54 | // Reset for other tests 55 | *hostsURL = "" 56 | hostsMap = map[string]string{} 57 | } 58 | 59 | func TestHostsOnly(t *testing.T) { 60 | // Reset the map and flags for clean testing 61 | hostsMap = map[string]string{} 62 | prevHostsOnly := *hostsOnly 63 | 64 | // Test when hosts-only is false (default) 65 | *hostsOnly = false 66 | assert.True(t, hostAllowed("any-host.com")) 67 | assert.True(t, hostAllowed("random.example.com")) 68 | 69 | // Set up some host mappings 70 | assert.NoError(t, hostsSetup("old-api.example.com=new-api.example.com,legacy.corp=modern.corp")) 71 | 72 | // Test when hosts-only is false - all hosts should be allowed 73 | *hostsOnly = false 74 | assert.True(t, hostAllowed("old-api.example.com")) 75 | assert.True(t, hostAllowed("legacy.corp")) 76 | assert.True(t, hostAllowed("unmapped-host.com")) 77 | 78 | // Test when hosts-only is true - only mapped hosts should be allowed 79 | *hostsOnly = true 80 | assert.True(t, hostAllowed("old-api.example.com")) 81 | assert.True(t, hostAllowed("legacy.corp")) 82 | assert.True(t, hostAllowed("subdomain.old-api.example.com")) // suffix match 83 | assert.False(t, hostAllowed("unmapped-host.com")) 84 | assert.False(t, hostAllowed("random.example.com")) 85 | 86 | // Test when hosts-only is true but no mappings exist 87 | hostsMap = map[string]string{} 88 | *hostsOnly = true 89 | assert.False(t, hostAllowed("any-host.com")) 90 | 91 | // Restore original state 92 | *hostsOnly = prevHostsOnly 93 | hostsMap = map[string]string{} 94 | } 95 | 96 | func TestHostRewriteDetailed(t *testing.T) { 97 | // Reset the map for clean testing 98 | hostsMap = map[string]string{} 99 | 100 | // Test basic host rewriting (backward compatibility) 101 | assert.NoError(t, hostsSetup("old-api.example.com=new-api.example.com")) 102 | 103 | result := hostRewriteDetailed("old-api.example.com") 104 | assert.Equal(t, "new-api.example.com", result.Host) 105 | assert.Equal(t, "", result.Scheme) 106 | assert.Equal(t, "", result.Port) 107 | assert.Equal(t, "", result.FullURL) 108 | 109 | // Test URL with protocol 110 | hostsMap = map[string]string{} 111 | assert.NoError(t, hostsSetup("legacy.corp=https://modern.corp.example.com")) 112 | 113 | result = hostRewriteDetailed("legacy.corp") 114 | assert.Equal(t, "modern.corp.example.com", result.Host) 115 | assert.Equal(t, "https", result.Scheme) 116 | assert.Equal(t, "", result.Port) 117 | assert.Equal(t, "https://modern.corp.example.com", result.FullURL) 118 | 119 | // Test URL with protocol and port 120 | hostsMap = map[string]string{} 121 | assert.NoError(t, hostsSetup("internal.api=http://new-internal.api:8080")) 122 | 123 | result = hostRewriteDetailed("internal.api") 124 | assert.Equal(t, "new-internal.api", result.Host) 125 | assert.Equal(t, "http", result.Scheme) 126 | assert.Equal(t, "8080", result.Port) 127 | assert.Equal(t, "http://new-internal.api:8080", result.FullURL) 128 | 129 | // Test HTTPS with non-standard port 130 | hostsMap = map[string]string{} 131 | assert.NoError(t, hostsSetup("secure.app=https://new-secure.app:9443")) 132 | 133 | result = hostRewriteDetailed("secure.app") 134 | assert.Equal(t, "new-secure.app", result.Host) 135 | assert.Equal(t, "https", result.Scheme) 136 | assert.Equal(t, "9443", result.Port) 137 | assert.Equal(t, "https://new-secure.app:9443", result.FullURL) 138 | 139 | // Test subdomain matching with URL replacement 140 | hostsMap = map[string]string{} 141 | assert.NoError(t, hostsSetup("api.legacy.com=https://api.modern.com:8443")) 142 | 143 | result = hostRewriteDetailed("service.api.legacy.com") 144 | assert.Equal(t, "service.api.modern.com", result.Host) 145 | assert.Equal(t, "https", result.Scheme) 146 | assert.Equal(t, "8443", result.Port) 147 | assert.Equal(t, "https://api.modern.com:8443", result.FullURL) 148 | 149 | // Test with no matching host 150 | result = hostRewriteDetailed("unmatched.example.com") 151 | assert.Equal(t, "unmatched.example.com", result.Host) 152 | assert.Equal(t, "", result.Scheme) 153 | assert.Equal(t, "", result.Port) 154 | assert.Equal(t, "", result.FullURL) 155 | 156 | // Reset for other tests 157 | hostsMap = map[string]string{} 158 | } 159 | 160 | func TestBackwardCompatibility(t *testing.T) { 161 | // Reset the map for clean testing 162 | hostsMap = map[string]string{} 163 | 164 | // Test that hostRewrite still works the same way for simple host mappings 165 | assert.NoError(t, hostsSetup("old.example.com=new.example.com")) 166 | assert.Equal(t, "new.example.com", hostRewrite("old.example.com")) 167 | 168 | // Test that hostRewrite returns just the host part for URL mappings 169 | hostsMap = map[string]string{} 170 | assert.NoError(t, hostsSetup("old.example.com=https://new.example.com:8080")) 171 | assert.Equal(t, "new.example.com", hostRewrite("old.example.com")) 172 | 173 | // Reset for other tests 174 | hostsMap = map[string]string{} 175 | } 176 | 177 | func TestProxyIntegration(t *testing.T) { 178 | // Reset the map for clean testing 179 | hostsMap = map[string]string{} 180 | 181 | // Test WebSocket URL conversion 182 | assert.NoError(t, hostsSetup("ws.example.com=https://ws.backend.com:8443")) 183 | 184 | // Create a mock request 185 | req := &http.Request{Host: "ws.example.com"} 186 | req.URL, _ = url.Parse("/socket") 187 | 188 | wsURL, err := http2ws(req) 189 | assert.NoError(t, err) 190 | assert.Equal(t, "wss://ws.backend.com:8443/socket", wsURL.String()) 191 | 192 | // Test HTTP backend URL conversion 193 | assert.NoError(t, hostsSetup("api.example.com=http://api.backend.com:8080")) 194 | 195 | rewrite := hostRewriteDetailed("api.example.com") 196 | assert.Equal(t, "api.backend.com", rewrite.Host) 197 | assert.Equal(t, "http", rewrite.Scheme) 198 | assert.Equal(t, "8080", rewrite.Port) 199 | assert.Equal(t, "http://api.backend.com:8080", rewrite.FullURL) 200 | 201 | // Reset for other tests 202 | hostsMap = map[string]string{} 203 | } 204 | -------------------------------------------------------------------------------- /oidc.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | 8 | oidc "github.com/coreos/go-oidc" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | var ( 13 | oidcIssuer = flag.String("oidc-issuer", "https://accounts.google.com", "OIDC issuer URL provided by IdP") 14 | oidcClientID = flag.String("oidc-client-id", "f8b8b020-4ec2-0135-6452-027de1ec0c4e43491", "OIDC client ID") 15 | oidcClientSecret = flag.String("oidc-client-secret", "cxLF74XOeRRFDJbKuJpZAOtL4pVPK1t2XGVrDbe5R", "OIDC client secret") 16 | 17 | oidcConfig oidcConfigI 18 | oidcVerifier oidcVerifierI 19 | 20 | getOIDCClaims = parseClaims 21 | ) 22 | 23 | type oidcClaims struct { 24 | Email string `json:"email"` 25 | } 26 | 27 | type oidcConfigI interface { 28 | AuthCodeURL(string, ...oauth2.AuthCodeOption) string 29 | Exchange(context.Context, string) (*oauth2.Token, error) 30 | } 31 | 32 | type oauth2ConfigWrapper struct { 33 | *oauth2.Config 34 | } 35 | 36 | func (w *oauth2ConfigWrapper) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { 37 | return w.Config.AuthCodeURL(state, opts...) 38 | } 39 | 40 | func (w *oauth2ConfigWrapper) Exchange(ctx context.Context, code string) (*oauth2.Token, error) { 41 | return w.Config.Exchange(ctx, code) 42 | } 43 | 44 | type oidcVerifierI interface { 45 | Verify(context.Context, string) (*oidc.IDToken, error) 46 | } 47 | 48 | func oidcSetup(issuer string) error { 49 | ctx := context.Background() 50 | provider, err := oidc.NewProvider(ctx, issuer) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // Configure an OpenID Connect aware OAuth2 client. 56 | oauth2Config := &oauth2.Config{ 57 | ClientID: *oidcClientID, 58 | ClientSecret: *oidcClientSecret, 59 | RedirectURL: "https://" + *host + "/oidc", 60 | 61 | // Discovery returns the OAuth2 endpoints. 62 | Endpoint: provider.Endpoint(), 63 | 64 | // "openid" is a required scope for OpenID Connect flows. 65 | Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, 66 | } 67 | 68 | oidcVerifier = provider.Verifier(&oidc.Config{ 69 | ClientID: oauth2Config.ClientID, 70 | }) 71 | oidcConfig = &oauth2ConfigWrapper{oauth2Config} 72 | return nil 73 | } 74 | 75 | func oidcVerify(code string) (string, error) { 76 | ctx := context.Background() 77 | token, err := oidcConfig.Exchange(ctx, code) 78 | if err != nil { 79 | return "", err 80 | } 81 | return oidcVerifyToken(ctx, token) 82 | } 83 | 84 | func oidcVerifyToken(ctx context.Context, token *oauth2.Token) (string, error) { 85 | rawID, ok := token.Extra("id_token").(string) 86 | if !ok { 87 | return "", fmt.Errorf("missing ID token") 88 | } 89 | return oidcVerifyTokenID(ctx, rawID) 90 | } 91 | 92 | func oidcVerifyTokenID(ctx context.Context, rawID string) (string, error) { 93 | var err error 94 | tokenID, err := oidcVerifier.Verify(ctx, rawID) 95 | if err != nil { 96 | return "", err 97 | } 98 | claims := new(oidcClaims) 99 | err = getOIDCClaims(claims, tokenID) 100 | if err != nil { 101 | return "", err 102 | } 103 | return claims.Email, nil 104 | } 105 | 106 | func parseClaims(claims *oidcClaims, tokenID *oidc.IDToken) error { 107 | return tokenID.Claims(claims) 108 | } 109 | -------------------------------------------------------------------------------- /oidc_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | oidc "github.com/coreos/go-oidc" 15 | "github.com/gorilla/securecookie" 16 | "github.com/stretchr/testify/assert" 17 | "golang.org/x/oauth2" 18 | ) 19 | 20 | var ( 21 | oidcServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | switch r.URL.Path { 23 | case "/.well-known/openid-configuration": 24 | err := json.NewEncoder(w).Encode(oidcWK) 25 | if err != nil { 26 | http.Error(w, err.Error(), 500) 27 | } 28 | return 29 | 30 | case "/token": 31 | fmt.Fprint(w, `{"typ":"JWT","alg":"HS256"}`) 32 | return 33 | 34 | case "/next": 35 | fmt.Fprint(w, "NEXT") 36 | return 37 | 38 | default: 39 | WithField("path", r.URL.Path).Error("Invalid OIDC Request") 40 | return 41 | 42 | } 43 | })) 44 | oidcWK = struct { 45 | Issuer string 46 | AuthorizationEndpoint string `json:"authorization_endpoint"` 47 | TokenEndpoint string `json:"token_endpoint"` 48 | }{ 49 | "/issuer", 50 | "/authorize", 51 | "/token", 52 | } 53 | ) 54 | 55 | type oidcMock struct{} 56 | 57 | func (o *oidcMock) AuthCodeURL(state string, opt ...oauth2.AuthCodeOption) string { 58 | return oidcServer.URL + "/AuthCodeURL" 59 | } 60 | 61 | func (o *oidcMock) Exchange(ctx context.Context, code string) (*oauth2.Token, error) { 62 | token := &oauth2.Token{} 63 | token.AccessToken = "AccessToken" 64 | token.Expiry = time.Now().Add(time.Hour) 65 | token = token.WithExtra(map[string]interface{}{"id_token": "IDToken"}) 66 | return token, nil 67 | } 68 | 69 | func (o *oidcMock) Verify(ctx context.Context, raw string) (*oidc.IDToken, error) { 70 | if raw == "err" { 71 | return nil, http.ErrHijacked 72 | } 73 | token := &oidc.IDToken{} 74 | 75 | getOIDCClaims = func(claims *oidcClaims, tokenID *oidc.IDToken) error { 76 | claims.Email = "user3@domain3.com" 77 | return nil 78 | } 79 | if raw == "claimsErr" { 80 | getOIDCClaims = func(_ *oidcClaims, _ *oidc.IDToken) error { 81 | return fmt.Errorf("test error") 82 | } 83 | } 84 | 85 | return token, nil 86 | } 87 | 88 | func init() { 89 | // *oidcIssuer = oidcServer.URL 90 | oidcWK.Issuer = oidcServer.URL + oidcWK.Issuer 91 | oidcWK.TokenEndpoint = oidcServer.URL + oidcWK.TokenEndpoint 92 | oidcWK.AuthorizationEndpoint = oidcServer.URL + oidcWK.AuthorizationEndpoint 93 | } 94 | 95 | func TestOIDCSetup(t *testing.T) { 96 | assert.Contains(t, oidcSetup("ftp://localhost").Error(), "unsupported protocol scheme") 97 | } 98 | 99 | func TestOIDCSuccess(t *testing.T) { 100 | mock := &oidcMock{} 101 | oidcConfig = mock 102 | oidcVerifier = mock 103 | 104 | // Test OIDC callback with state and next parameters 105 | request := httptest.NewRequest("GET", "/oidc?state=barbaz&next=localhost/next", nil) 106 | 107 | vals := map[string]interface{}{"state": "barbaz", "next": oidcServer.URL + "/next"} 108 | cookieValue, err := securecookie.EncodeMulti(*cookieName, &vals, store.Codecs...) 109 | assert.NoError(t, err) 110 | request.AddCookie(&http.Cookie{Name: *cookieName, Value: cookieValue}) 111 | 112 | request.Host = *host 113 | w := httptest.NewRecorder() 114 | testMux.ServeHTTP(w, request) 115 | 116 | resp := w.Result() 117 | body, _ := io.ReadAll(resp.Body) 118 | // The OIDC handler redirects to the next URL, so we expect a 302 119 | assert.Equal(t, 302, resp.StatusCode) 120 | assert.Contains(t, string(body), "Found") 121 | 122 | // Test POST request to OIDC server directly 123 | b := strings.NewReader("POSTED") 124 | request, err = http.NewRequest("POST", oidcServer.URL+"/next", b) 125 | assert.NoError(t, err) 126 | request.AddCookie(&http.Cookie{Name: *cookieName, Value: cookieValue}) 127 | request.Host = *host 128 | 129 | resp, err = http.DefaultClient.Do(request) 130 | assert.NoError(t, err) 131 | assert.Equal(t, 200, resp.StatusCode) 132 | respBody, err := io.ReadAll(resp.Body) 133 | assert.NoError(t, err) 134 | assert.Equal(t, "NEXT", string(respBody)) 135 | } 136 | 137 | func TestOIDCVerifyToken(t *testing.T) { 138 | token := &oauth2.Token{} 139 | s, err := oidcVerifyToken(context.TODO(), token) 140 | assert.Empty(t, s) 141 | assert.Equal(t, "missing ID token", err.Error()) 142 | } 143 | 144 | func TestOIDCVerifyTokenID(t *testing.T) { 145 | email, err := oidcVerifyTokenID(context.TODO(), "err") 146 | assert.Equal(t, "", email) 147 | assert.Equal(t, http.ErrHijacked, err) 148 | 149 | testErr := fmt.Errorf("test error") 150 | email, err = oidcVerifyTokenID(context.TODO(), "claimsErr") 151 | assert.Equal(t, "", email) 152 | assert.Equal(t, testErr, err) 153 | 154 | email, err = oidcVerifyTokenID(context.TODO(), "rawID") 155 | assert.Equal(t, "user3@domain3.com", email) 156 | assert.NoError(t, err) 157 | } 158 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "net/url" 7 | "sync" 8 | 9 | "github.com/koding/websocketproxy" 10 | ) 11 | 12 | var ( 13 | hostProxy = sync.Map{} 14 | ) 15 | 16 | func http2ws(r *http.Request) (*url.URL, error) { 17 | rewrite := hostRewriteDetailed(r.Host) 18 | 19 | var target string 20 | if rewrite.FullURL != "" { 21 | // Convert http/https to ws/wss for WebSocket 22 | if rewrite.Scheme == "https" { 23 | target = "wss://" + rewrite.Host 24 | if rewrite.Port != "" { 25 | target += ":" + rewrite.Port 26 | } 27 | } else { 28 | target = "ws://" + rewrite.Host 29 | if rewrite.Port != "" { 30 | target += ":" + rewrite.Port 31 | } 32 | } 33 | target += r.URL.RequestURI() 34 | } else { 35 | // Default to secure WebSocket 36 | target = "wss://" + rewrite.Host + r.URL.RequestURI() 37 | } 38 | return url.Parse(target) 39 | } 40 | 41 | func nexthop(w http.ResponseWriter, r *http.Request) { 42 | var ( 43 | rewrite = hostRewriteDetailed(r.Host) 44 | nextHost = rewrite.Host 45 | nextProxy http.Handler 46 | ) 47 | 48 | // Use full URL if available for backend connection 49 | var targetBase string 50 | if rewrite.FullURL != "" { 51 | targetBase = rewrite.FullURL 52 | } else { 53 | targetBase = nextHost 54 | } 55 | 56 | v, ok := hostProxy.Load(nextHost) 57 | if ok { 58 | nextProxy, ok = v.(*httputil.ReverseProxy) 59 | } 60 | if !ok && *learnNexthops { 61 | nextProxy = learn(targetBase) 62 | if nextProxy != nil { 63 | hostProxy.Store(nextHost, nextProxy) 64 | ok = true 65 | } 66 | } 67 | 68 | if !ok || nextProxy == nil { 69 | // unconfigured 70 | errorHandler(w, 404, *fouroFourMessage) 71 | return 72 | } 73 | 74 | if r.Header.Get("Upgrade") == "websocket" { 75 | nextProxy, _ = websocketproxyNew(r) 76 | } 77 | nextProxy.ServeHTTP(w, r) 78 | } 79 | 80 | func newSHRP(target *url.URL) *httputil.ReverseProxy { 81 | p := httputil.NewSingleHostReverseProxy(target) 82 | p.ModifyResponse = func(resp *http.Response) error { 83 | logRoundtrip(resp) 84 | return nil 85 | } 86 | return p 87 | } 88 | 89 | func reproxy() error { 90 | cleanup := map[string]bool{} 91 | hostProxy.Range(func(key interface{}, value interface{}) bool { 92 | if key, ok := key.(string); ok { 93 | cleanup[key] = true 94 | } 95 | return true 96 | }) 97 | var lerr error 98 | sites.RLock() 99 | for _, v := range sites.m { 100 | for x := range v { 101 | u, err := url.Parse(x) 102 | if err != nil { 103 | lerr = err 104 | } else { 105 | delete(cleanup, u.Host) 106 | hostProxy.Store(u.Host, newSHRP(u)) 107 | } 108 | } 109 | } 110 | sites.RUnlock() 111 | for key := range cleanup { 112 | hostProxy.Delete(key) 113 | } 114 | return lerr 115 | } 116 | 117 | func websocketproxyDirector(incoming *http.Request, out http.Header) { 118 | out.Set("User-Agent", incoming.UserAgent()) 119 | out.Set("X-Forwarded-Proto", "https") 120 | } 121 | 122 | func websocketproxyNew(r *http.Request) (*websocketproxy.WebsocketProxy, error) { 123 | ws, err := http2ws(r) 124 | p := websocketproxy.NewProxy(ws) 125 | p.Director = websocketproxyDirector 126 | return p, err 127 | } 128 | 129 | func websocketproxyCheckOrigin(r *http.Request) bool { 130 | return true 131 | } 132 | -------------------------------------------------------------------------------- /proxy_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gorilla/websocket" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func init() { 13 | hostProxy.Store("test.com", nil) 14 | } 15 | 16 | func TestH2W(t *testing.T) { 17 | req, err := http.NewRequest("GET", "/foo?key=bar", nil) 18 | assert.NoError(t, err) 19 | req.Host = "websocketserver:9443" 20 | actual, err := http2ws(req) 21 | assert.NoError(t, err) 22 | assert.Equal(t, "wss://websocketserver:9443/foo?key=bar", actual.String()) 23 | 24 | req.Host = "websock etserver:9443" 25 | actual, err = http2ws(req) 26 | assert.Error(t, err) 27 | assert.Nil(t, actual) 28 | } 29 | 30 | func TestReproxyParseError(t *testing.T) { 31 | test := sites.m["test"] 32 | sites.m["test"] = map[string]bool{":": true} 33 | err := reproxy() 34 | assert.Contains(t, err.Error(), "missing protocol scheme") 35 | sites.m["test"] = test 36 | } 37 | 38 | func TestWebsocketEcho(t *testing.T) { 39 | // echo.websocket.org offline as of 2019/01/29 40 | t.SkipNow() 41 | 42 | server := httptest.NewServer(http.HandlerFunc(nexthop)) 43 | defer server.Close() 44 | 45 | h := http.Header{"Host": []string{"echo.websocket.org"}} 46 | c, _, err := websocket.DefaultDialer.Dial("ws:"+server.URL[5:], h) 47 | assert.NotNil(t, c) 48 | assert.NoError(t, err) 49 | assert.NoError(t, c.WriteJSON(map[string]string{"test": "123"})) 50 | v := map[string]string{} 51 | assert.NoError(t, c.ReadJSON(&v)) 52 | assert.Equal(t, "123", v["test"]) 53 | } 54 | 55 | func TestWebsocketNew(t *testing.T) { 56 | r, err := http.NewRequest("GET", "https://demos.kaazing.com/echo", nil) 57 | assert.NoError(t, err) 58 | assert.True(t, websocketproxyCheckOrigin(r)) 59 | 60 | p, err := websocketproxyNew(r) 61 | assert.NoError(t, err) 62 | 63 | assert.Equal(t, "wss:"+r.URL.String()[6:], p.Backend(r).String()) 64 | } 65 | 66 | func TestWSPDirector(t *testing.T) { 67 | incoming, err := http.NewRequest("GET", "https://localhost", nil) 68 | assert.NoError(t, err) 69 | incoming.Header.Set("User-Agent", "User-Agent") 70 | 71 | out := http.Header{} 72 | websocketproxyDirector(incoming, out) 73 | 74 | assert.Equal(t, out.Get("User-Agent"), "User-Agent") 75 | assert.Equal(t, out.Get("X-Forwarded-Proto"), "https") 76 | } 77 | -------------------------------------------------------------------------------- /saml.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "flag" 9 | "net/http" 10 | "net/url" 11 | 12 | "github.com/crewjam/saml" 13 | "github.com/crewjam/saml/samlsp" 14 | "github.com/pkg/errors" 15 | 16 | dsig "github.com/russellhaering/goxmldsig" 17 | ) 18 | 19 | var ( 20 | samlCert = flag.String("saml-cert-file", "example/myservice.cert", "SAML SP path to cert.pem") 21 | samlKey = flag.String("saml-key-file", "example/myservice.key", "SAML SP path to key.pem") 22 | 23 | samlID = flag.String("saml-entity-id", "", "SAML SP entity ID (blank defaults to beyond-host)") 24 | samlIDP = flag.String("saml-metadata-url", "", "SAML metadata URL from IdP (blank disables SAML)") 25 | 26 | samlNIDF = flag.String("saml-nameid-format", "email", "SAML SP option: {email, persistent, transient, unspecified}") 27 | samlAttr = flag.String("saml-session-key", "email", "SAML attribute to map from session") 28 | 29 | samlSignRequests = flag.Bool("saml-sign-requests", false, "SAML SP signs authentication requests") 30 | samlSignMethod = flag.String("saml-signature-method", "", "SAML SP option: {sha1, sha256, sha512}") 31 | 32 | samlSP *samlsp.Middleware 33 | ) 34 | 35 | func samlSetup() error { 36 | if *samlIDP == "" { 37 | return nil 38 | } 39 | if *samlID == "" { 40 | *samlID = *host 41 | } 42 | 43 | keyPair, err := tls.LoadX509KeyPair(*samlCert, *samlKey) 44 | if err != nil { 45 | return err 46 | } 47 | keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | idpMetadataURL, err := url.Parse(*samlIDP) 53 | if err != nil { 54 | return err 55 | } 56 | idpMetadata, err := samlsp.FetchMetadata( 57 | context.Background(), http.DefaultClient, 58 | *idpMetadataURL) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | rootURL, _ := url.Parse("https://" + *host) 64 | samlSP, err = samlsp.New(samlsp.Options{ 65 | EntityID: *samlID, 66 | SignRequest: *samlSignRequests, 67 | URL: *rootURL, 68 | 69 | Certificate: keyPair.Leaf, 70 | IDPMetadata: idpMetadata, 71 | Key: keyPair.PrivateKey.(*rsa.PrivateKey), 72 | 73 | AllowIDPInitiated: true, 74 | }) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | switch *samlNIDF { 80 | case "email": 81 | samlSP.ServiceProvider.AuthnNameIDFormat = saml.EmailAddressNameIDFormat 82 | case "persistent": 83 | samlSP.ServiceProvider.AuthnNameIDFormat = saml.PersistentNameIDFormat 84 | case "transient": 85 | samlSP.ServiceProvider.AuthnNameIDFormat = saml.TransientNameIDFormat 86 | case "unspecified": 87 | samlSP.ServiceProvider.AuthnNameIDFormat = saml.UnspecifiedNameIDFormat 88 | case "": 89 | default: 90 | return errors.Errorf("invalid saml-nameid-format: \"%s\"", *samlNIDF) 91 | } 92 | 93 | switch *samlSignMethod { 94 | case "sha1": 95 | samlSP.ServiceProvider.SignatureMethod = dsig.RSASHA1SignatureMethod 96 | case "sha256": 97 | samlSP.ServiceProvider.SignatureMethod = dsig.RSASHA256SignatureMethod 98 | case "sha512": 99 | samlSP.ServiceProvider.SignatureMethod = dsig.RSASHA512SignatureMethod 100 | case "": 101 | default: 102 | return errors.Errorf("invalid saml-signature-method: \"%s\"", *samlSignMethod) 103 | } 104 | return nil 105 | } 106 | 107 | func samlFilter(w http.ResponseWriter, r *http.Request) bool { 108 | samlSession, _ := samlSP.Session.GetSession(r) 109 | if _, ok := samlSession.(samlsp.SessionWithAttributes); !ok { 110 | // sessions without mappings will redirect infinitely 111 | return false 112 | } 113 | samlAttributes := samlSession.(samlsp.SessionWithAttributes).GetAttributes() 114 | user := samlAttributes.Get(*samlAttr) 115 | if user == "" { 116 | // nil IdP assertion unlikely 117 | return false 118 | } 119 | 120 | session, err := store.Get(r, *cookieName) 121 | if err != nil { 122 | session = store.New(*cookieName) 123 | } 124 | session.Values["user"] = user 125 | session.Save(w) 126 | samlSP.Session.DeleteSession(w, r) 127 | return true 128 | } 129 | -------------------------------------------------------------------------------- /saml_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | func init() { 4 | // *samlIDP = "https://samltest.id/saml/idp" 5 | } 6 | -------------------------------------------------------------------------------- /setup.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/tls" 6 | "encoding/hex" 7 | "flag" 8 | "fmt" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/dghubble/sessions" 13 | "github.com/koding/websocketproxy" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | var ( 18 | debug = flag.Bool("debug", true, "set debug loglevel") 19 | 20 | host = flag.String("beyond-host", "beyond.myorg.net", "hostname of self") 21 | 22 | healthPath = flag.String("health-path", "/healthz/ping", "URL of the health endpoint") 23 | healthReply = flag.String("health-reply", "ok", "response body of the health endpoint") 24 | 25 | cookieAge = flag.Int("cookie-age", 3600*6, "MaxAge setting in seconds") 26 | cookieDom = flag.String("cookie-domain", ".myorg.net", "session cookie domain") 27 | cookieKey = flag.String("cookie-key", "", `64-char hex key for cookie encryption (example: "t8yG1gmeEyeb7pQpw544UeCTyDfPkE6uQ599vrruZRhLFC144thCRZpyHM7qGDjt")`) 28 | cookieName = flag.String("cookie-name", "beyond", "session cookie name") 29 | 30 | fouroFourMessage = flag.String("404-message", "Please contact the application administrators to setup access.", "message to use when backend apps do not respond") 31 | fouroOneCode = flag.Int("401-code", 418, "status to respond when a user needs authentication") 32 | headerPrefix = flag.String("header-prefix", "Beyond", "prefix extra headers with this string") 33 | 34 | skipVerify = flag.Bool("insecure-skip-verify", false, "allow TLS backends without valid certificates") 35 | wsCompress = flag.Bool("websocket-compression", false, "allow websocket transport compression (gorilla/experimental)") 36 | 37 | store *sessions.CookieStore 38 | 39 | tlsConfig = &tls.Config{} 40 | ) 41 | 42 | // generateRandomKey creates a 32-byte random key encoded as hex string 43 | func generateRandomKey() (string, error) { 44 | key := make([]byte, 32) 45 | _, err := rand.Read(key) 46 | if err != nil { 47 | return "", err 48 | } 49 | return hex.EncodeToString(key), nil 50 | } 51 | 52 | // Setup initializes all configured modules 53 | func Setup() error { 54 | if *debug { 55 | logrus.SetLevel(logrus.DebugLevel) 56 | } 57 | if len(*cookieKey) == 0 { 58 | // Generate random cookie key for single instance deployments 59 | key, err := generateRandomKey() 60 | if err != nil { 61 | return fmt.Errorf("failed to generate cookie key: %v", err) 62 | } 63 | *cookieKey = key 64 | 65 | logrus.Warn("No cookie key provided, generated random key for this session:") 66 | logrus.Warnf(" -cookie-key %s", *cookieKey) 67 | logrus.Warn("IMPORTANT: Sessions will not persist across restarts. Set explicit key for production use.") 68 | } 69 | 70 | // Validate key length (should be 64 hex chars = 32 bytes) 71 | if len(*cookieKey) != 64 { 72 | return fmt.Errorf("cookie key must be exactly 64 hex characters (32 bytes), got %d", len(*cookieKey)) 73 | } 74 | 75 | // setup encrypted cookies - use the key for both authentication and encryption 76 | keyBytes, err := hex.DecodeString(*cookieKey) 77 | if err != nil { 78 | return fmt.Errorf("cookie key must be valid hex: %v", err) 79 | } 80 | store = sessions.NewCookieStore(keyBytes, keyBytes) 81 | store.Config.Domain = *cookieDom 82 | store.Config.MaxAge = *cookieAge 83 | store.Config.HTTPOnly = true 84 | store.Config.SameSite = http.SameSiteNoneMode 85 | store.Config.Secure = true 86 | 87 | // setup backend encryption 88 | tlsConfig.InsecureSkipVerify = *skipVerify 89 | http.DefaultTransport = &http.Transport{TLSClientConfig: tlsConfig} 90 | 91 | // setup websockets 92 | if websocketproxy.DefaultDialer.TLSClientConfig == nil { 93 | websocketproxy.DefaultDialer.TLSClientConfig = &tls.Config{} 94 | } 95 | websocketproxy.DefaultDialer.TLSClientConfig.InsecureSkipVerify = *skipVerify 96 | websocketproxy.DefaultDialer.EnableCompression = *wsCompress 97 | websocketproxy.DefaultUpgrader.EnableCompression = *wsCompress 98 | websocketproxy.DefaultUpgrader.CheckOrigin = websocketproxyCheckOrigin 99 | 100 | dURLs := []string{*dockerBase} 101 | if len(*dockerURLs) > 0 { 102 | dURLs = append(dURLs, strings.Split(*dockerURLs, ",")...) 103 | } 104 | for _, k := range strings.Split(*ghpHost, ",") { 105 | ghpHosts[k] = true 106 | } 107 | 108 | err = dockerSetup(dURLs...) 109 | if err == nil { 110 | err = federateSetup() 111 | } 112 | if err == nil { 113 | err = hostsSetup(*hostsCSV) 114 | } 115 | if err == nil { 116 | err = refreshHosts() 117 | } 118 | if err == nil { 119 | err = logSetup() 120 | } 121 | if err == nil { 122 | err = oidcSetup(*oidcIssuer) 123 | } 124 | if err == nil { 125 | err = samlSetup() 126 | } 127 | if err == nil { 128 | err = refreshFence() 129 | } 130 | if err == nil { 131 | err = refreshSites() 132 | } 133 | if err == nil { 134 | err = refreshAllowlist() 135 | } 136 | if err == nil { 137 | err = reproxy() 138 | } 139 | return err 140 | } 141 | -------------------------------------------------------------------------------- /setup_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func init() { 10 | *cookieKey = "a8b91cde2e3eb7fcd544ece2bdf3ce6d0599fbbcbb1cfc144e1c2bf3cd7a13de" 11 | } 12 | 13 | func TestSetupAutoGenerate(t *testing.T) { 14 | prev := *cookieKey 15 | *cookieKey = "" 16 | err := Setup() 17 | assert.NoError(t, err) 18 | assert.Len(t, *cookieKey, 64) // Should be 64 hex chars 19 | *cookieKey = prev 20 | } 21 | 22 | func TestSetupBadKeyLength(t *testing.T) { 23 | prev := *cookieKey 24 | *cookieKey = "short" 25 | err := Setup() 26 | assert.Error(t, err) 27 | assert.Contains(t, err.Error(), "cookie key must be exactly 64 hex characters") 28 | *cookieKey = prev 29 | } 30 | 31 | func TestSetupBadKeyFormat(t *testing.T) { 32 | prev := *cookieKey 33 | *cookieKey = "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg" 34 | err := Setup() 35 | assert.Error(t, err) 36 | assert.Contains(t, err.Error(), "cookie key must be valid hex") 37 | *cookieKey = prev 38 | } 39 | -------------------------------------------------------------------------------- /token.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | cache "github.com/patrickmn/go-cache" 11 | ) 12 | 13 | var ( 14 | tokenBase = flag.String("token-base", "", "token server URL prefix (eg. https://api.github.com/user)") 15 | tokenGQL = flag.String("token-graphql", "", "GraphQL URL for auth (eg. https://api.github.com/graphql)") 16 | tokenGQLQ = flag.String("token-graphql-query", `{"query": "query { viewer { login }}"}`, "") 17 | 18 | tokenCache = cache.New(10*time.Minute, 10*time.Minute) 19 | 20 | tokenTypes = map[string]bool{ 21 | "bearer": true, 22 | "token": true, 23 | } 24 | ) 25 | 26 | // {"data":{"viewer":{"login":"github[bot]"}}} 27 | 28 | func tokenAuth(r *http.Request) string { 29 | if *tokenBase == "" && *tokenGQL == "" { 30 | return "" 31 | } 32 | 33 | u, token, ok := r.BasicAuth() 34 | if ok && (token == "x-oauth-basic" || token == "") { 35 | token = u 36 | } 37 | if token == "" { 38 | token = r.URL.Query().Get("access_token") 39 | } 40 | if token == "" { 41 | parts := strings.Split(r.Header.Get("Authorization"), " ") 42 | if len(parts) > 1 && tokenTypes[strings.ToLower(parts[0])] { 43 | token = parts[1] 44 | } 45 | } 46 | if token == "" { 47 | return "" 48 | } 49 | 50 | if v, ex := tokenCache.Get(token); ex { 51 | if v, ok := v.(string); ok { 52 | return v 53 | } 54 | } 55 | 56 | var ( 57 | req *http.Request 58 | err error 59 | ) 60 | 61 | switch { 62 | case *tokenGQL != "": 63 | req, err = http.NewRequest("POST", *tokenGQL, strings.NewReader(*tokenGQLQ)) 64 | if err != nil { 65 | Error(err) 66 | return "" 67 | } 68 | 69 | default: 70 | req, err = http.NewRequest("GET", *tokenBase, nil) 71 | if err != nil { 72 | Error(err) 73 | return "" 74 | } 75 | 76 | } 77 | 78 | req.Header.Set("Authorization", "Bearer "+token) 79 | resp, err := http.DefaultClient.Do(req) 80 | if err != nil { 81 | Error(err) 82 | return "" 83 | } 84 | defer resp.Body.Close() 85 | if resp.StatusCode != 200 { 86 | tokenCache.Set(token, "", cache.DefaultExpiration) 87 | return "" 88 | } 89 | 90 | switch { 91 | case *tokenGQL != "": 92 | v := new(gqlResponse) 93 | err = json.NewDecoder(resp.Body).Decode(v) 94 | if err != nil { 95 | Error(err) 96 | return "" 97 | } 98 | tokenCache.Set(token, v.Data.Viewer.Login, cache.DefaultExpiration) 99 | return v.Data.Viewer.Login 100 | 101 | default: 102 | v := &tokenUser{} 103 | err = json.NewDecoder(resp.Body).Decode(v) 104 | if err != nil { 105 | Error(err) 106 | return "" 107 | } 108 | tokenCache.Set(token, v.Login, cache.DefaultExpiration) 109 | return v.Login 110 | } 111 | } 112 | 113 | type tokenUser struct { 114 | Login string 115 | Email string 116 | } 117 | 118 | type gqlResponse struct { 119 | Data struct { 120 | Viewer struct { 121 | Login string `json:"login"` 122 | } `json:"viewer"` 123 | } `json:"data"` 124 | } 125 | -------------------------------------------------------------------------------- /token_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func init() { 16 | // Setup file transport for ACL 17 | t := &http.Transport{} 18 | t.RegisterProtocol("file", http.NewFileTransport(http.Dir("/"))) 19 | httpACL.Transport = t 20 | 21 | // Load the fence and sites configuration for testing 22 | cwd, _ := os.Getwd() 23 | *fenceURL = "file://" + cwd + "/example/fence.json" 24 | *sitesURL = "file://" + cwd + "/example/sites.json" 25 | refreshFence() 26 | refreshSites() 27 | 28 | // Setup token authentication 29 | *tokenBase = tokenServer.URL + "/?access_token=" 30 | } 31 | 32 | var ( 33 | tokenTestTokenUsers = map[string]string{ 34 | "932928c0a4edf9878ee0257a1d8f4d06adaaffee": "user1", 35 | "257a1d8f4d06adaaffee932928c0a4edf9878ee0": "vendor@gmail.com", 36 | } 37 | tokenTestUserTokens = map[string]string{ 38 | "user1": "932928c0a4edf9878ee0257a1d8f4d06adaaffee", 39 | "vendor@gmail.com": "257a1d8f4d06adaaffee932928c0a4edf9878ee0", 40 | } 41 | 42 | tokenServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | if r.URL.Query().Get("access_token") == "invalid" { 44 | _, err := io.WriteString(w, "{") 45 | if err != nil { 46 | errorHandler(w, 500, err.Error()) 47 | } 48 | return 49 | } 50 | authorization := r.Header.Get("Authorization") 51 | user := tokenTestTokenUsers[r.URL.Query().Get("access_token")] 52 | if user == "" && strings.Contains(authorization, " ") { 53 | user = tokenTestTokenUsers[strings.Split(authorization, " ")[1]] 54 | } 55 | err := json.NewEncoder(w).Encode(tokenUser{Login: user}) 56 | if err != nil { 57 | errorHandler(w, 500, err.Error()) 58 | } 59 | })) 60 | ) 61 | 62 | func TestTokenError(t *testing.T) { 63 | *tokenBase = "https://foo.bar?" 64 | 65 | r, err := http.NewRequest("GET", "/", nil) 66 | assert.NoError(t, err) 67 | 68 | assert.Equal(t, "", tokenAuth(r)) 69 | r.Header.Set("Authorization", "token test") 70 | assert.Equal(t, "", tokenAuth(r)) 71 | 72 | *tokenBase = tokenServer.URL + "/?access_token=" 73 | r.Header.Set("Authorization", "token invalid") 74 | assert.Equal(t, "", tokenAuth(r)) 75 | } 76 | 77 | func TestTokenBasic(t *testing.T) { 78 | r, err := http.NewRequest("GET", "/", nil) 79 | assert.NoError(t, err) 80 | 81 | r.SetBasicAuth(tokenTestUserTokens["user1"], "x-oauth-basic") 82 | login1 := tokenAuth(r) 83 | r.SetBasicAuth("", tokenTestUserTokens["user1"]) 84 | login2 := tokenAuth(r) 85 | assert.Equal(t, "user1", login1) 86 | assert.Equal(t, "user1", login2) 87 | 88 | r.SetBasicAuth(tokenTestUserTokens["user1"], "foobar") 89 | assert.Equal(t, "", tokenAuth(r)) 90 | } 91 | 92 | func TestTokenFederation(t *testing.T) { 93 | r, err := http.NewRequest("GET", "/", nil) 94 | assert.NoError(t, err) 95 | 96 | r.Header.Set("Authorization", "token test") 97 | assert.Equal(t, "", tokenAuth(r)) 98 | 99 | r.Header.Set("Authorization", "token "+tokenTestUserTokens["user1"]) 100 | login1 := tokenAuth(r) 101 | login2 := tokenAuth(r) 102 | assert.Equal(t, "user1", login1) 103 | assert.Equal(t, "user1", login2) 104 | } 105 | 106 | func TestTokenSuccess(t *testing.T) { 107 | // Test token authentication with Authorization header 108 | request := httptest.NewRequest("GET", "/ip", nil) 109 | request.Header.Set("Authorization", "Token "+tokenTestUserTokens["user1"]) 110 | request.Host = "httpbin.org" 111 | w := httptest.NewRecorder() 112 | testMux.ServeHTTP(w, request) 113 | 114 | resp := w.Result() 115 | body, _ := io.ReadAll(resp.Body) 116 | assert.Equal(t, 200, resp.StatusCode) 117 | assert.Equal(t, "{\n \"origin\"", strings.Split(string(body), ":")[0]) 118 | 119 | // Test token authentication with Basic Auth 120 | request = httptest.NewRequest("GET", "/ip", nil) 121 | request.SetBasicAuth("user1", tokenTestUserTokens["user1"]) 122 | request.Host = "httpbin.org" 123 | w = httptest.NewRecorder() 124 | testMux.ServeHTTP(w, request) 125 | 126 | resp = w.Result() 127 | body, _ = io.ReadAll(resp.Body) 128 | assert.Equal(t, 200, resp.StatusCode) 129 | assert.Equal(t, "{\n \"origin\"", strings.Split(string(body), ":")[0]) 130 | 131 | // Test ACL 403 - vendor@gmail.com should be blocked by ACL 132 | request = httptest.NewRequest("GET", "/", nil) 133 | request.Header.Set("Authorization", "Token "+tokenTestUserTokens["vendor@gmail.com"]) 134 | request.Host = "example.com" 135 | w = httptest.NewRecorder() 136 | testMux.ServeHTTP(w, request) 137 | 138 | resp = w.Result() 139 | body, _ = io.ReadAll(resp.Body) 140 | assert.Equal(t, 403, resp.StatusCode) 141 | assert.Contains(t, string(body), "Access Denied") 142 | } 143 | -------------------------------------------------------------------------------- /web.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | var ( 10 | homeURL = flag.String("home-url", "https://google.com", "redirect users here from root") 11 | ) 12 | 13 | // NewMux mounts all configured web handlers 14 | func NewMux() http.Handler { 15 | mux := http.NewServeMux() 16 | 17 | mux.HandleFunc(*healthPath, func(rw http.ResponseWriter, r *http.Request) { 18 | fmt.Fprint(rw, *healthReply) 19 | }) 20 | 21 | mux.HandleFunc(*host+"/federate", federate) 22 | mux.HandleFunc(*host+"/federate/verify", federateVerify) 23 | 24 | mux.HandleFunc(*host+"/launch", handleLaunch) 25 | mux.HandleFunc(*host+"/oidc", handleOIDC) 26 | if samlSP != nil { 27 | mux.HandleFunc(*host+"/saml/", samlSP.ServeHTTP) 28 | } 29 | mux.Handle(*host+"/", http.RedirectHandler(*homeURL, http.StatusTemporaryRedirect)) 30 | 31 | for _, ds := range dockerServers { 32 | ds.RegisterHandlers(mux) 33 | } 34 | 35 | mux.HandleFunc("/", handler) 36 | 37 | return mux 38 | } 39 | -------------------------------------------------------------------------------- /web_test.go: -------------------------------------------------------------------------------- 1 | package beyond 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var ( 16 | echoServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | b, err := io.ReadAll(r.Body) 18 | if err != nil { 19 | log.Println(err) 20 | } 21 | switch string(b) { 22 | case "ping": 23 | fmt.Fprint(w, "pong") 24 | default: 25 | fmt.Fprint(w, string(b)) 26 | } 27 | })) 28 | 29 | testMux http.Handler 30 | 31 | // Test token for basic auth testing 32 | webTestUserTokens = map[string]string{ 33 | "user1": "932928c0a4edf9878ee0257a1d8f4d06adaaffee", 34 | } 35 | ) 36 | 37 | func init() { 38 | Setup() 39 | testMux = NewMux() 40 | } 41 | 42 | func TestWebPOST(t *testing.T) { 43 | server := httptest.NewServer(testMux) 44 | defer server.Close() 45 | 46 | // Test successful request with valid basic auth 47 | request, err := http.NewRequest("POST", server.URL+"/", strings.NewReader("ping")) 48 | assert.NoError(t, err) 49 | request.Host = echoServer.URL[7:] // strip the http:// 50 | request.SetBasicAuth("", webTestUserTokens["user1"]) 51 | 52 | client := &http.Client{} 53 | response, err := client.Do(request) 54 | assert.NoError(t, err) 55 | defer response.Body.Close() 56 | 57 | body, err := io.ReadAll(response.Body) 58 | assert.NoError(t, err) 59 | assert.Equal(t, 200, response.StatusCode) 60 | assert.Equal(t, "pong", string(body)) 61 | 62 | // Test request without authentication 63 | request, err = http.NewRequest("POST", server.URL+"/", strings.NewReader("aliens")) 64 | assert.NoError(t, err) 65 | request.Host = echoServer.URL[7:] // strip the http:// 66 | 67 | response, err = client.Do(request) 68 | assert.NoError(t, err) 69 | defer response.Body.Close() 70 | 71 | assert.Equal(t, *fouroOneCode, response.StatusCode) 72 | } 73 | --------------------------------------------------------------------------------