├── .github └── workflows │ └── docker-publish.yml ├── .ruby-version ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── config.ru ├── docker-compose.yml └── proxy.rb /.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 | schedule: 10 | - cron: '35 7 * * *' 11 | push: 12 | branches: [ "main" ] 13 | # Publish semver tags as releases. 14 | tags: [ 'v*.*.*' ] 15 | pull_request: 16 | branches: [ "main" ] 17 | 18 | env: 19 | # Use docker.io for Docker Hub if empty 20 | REGISTRY: ghcr.io 21 | # github.repository as / 22 | IMAGE_NAME: ${{ github.repository }} 23 | 24 | 25 | jobs: 26 | build: 27 | 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | packages: write 32 | # This is used to complete the identity challenge 33 | # with sigstore/fulcio when running outside of PRs. 34 | id-token: write 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v3 39 | 40 | # Workaround: https://github.com/docker/build-push-action/issues/461 41 | - name: Setup Docker buildx 42 | uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf 43 | 44 | # Login against a Docker registry except on PR 45 | # https://github.com/docker/login-action 46 | - name: Log into registry ${{ env.REGISTRY }} 47 | if: github.event_name != 'pull_request' 48 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 49 | with: 50 | registry: ${{ env.REGISTRY }} 51 | username: ${{ github.actor }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | # Extract metadata (tags, labels) for Docker 55 | # https://github.com/docker/metadata-action 56 | - name: Extract Docker metadata 57 | id: meta 58 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 59 | with: 60 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 61 | 62 | # Build and push Docker image with Buildx (don't push on PR) 63 | # https://github.com/docker/build-push-action 64 | - name: Build and push Docker image 65 | id: build-and-push 66 | uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a 67 | with: 68 | context: . 69 | push: ${{ github.event_name != 'pull_request' }} 70 | tags: ${{ steps.meta.outputs.tags }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | 73 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.1 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1.1-bullseye 2 | WORKDIR /app 3 | 4 | COPY Gemfile Gemfile.* . 5 | RUN bundle install 6 | 7 | COPY . . 8 | 9 | CMD ["bundle", "exec", "rackup"] -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "sinatra" 4 | gem "puma" 5 | gem "sorbet-runtime" 6 | gem "excon" 7 | 8 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | excon (0.92.4) 5 | mustermann (2.0.2) 6 | ruby2_keywords (~> 0.0.1) 7 | nio4r (2.5.8) 8 | puma (5.6.5) 9 | nio4r (~> 2.0) 10 | rack (2.2.4) 11 | rack-protection (2.2.2) 12 | rack 13 | ruby2_keywords (0.0.5) 14 | sinatra (2.2.2) 15 | mustermann (~> 2.0) 16 | rack (~> 2.2) 17 | rack-protection (= 2.2.2) 18 | tilt (~> 2.0) 19 | sorbet-runtime (0.5.10439) 20 | tilt (2.0.11) 21 | 22 | PLATFORMS 23 | x86_64-darwin-20 24 | 25 | DEPENDENCIES 26 | excon 27 | puma 28 | sinatra 29 | sorbet-runtime 30 | 31 | BUNDLED WITH 32 | 2.3.8 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Pete Keen 2022 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailscale 1Password Secrets Automation Proxy 2 | 3 | (there's probably a more clever name for this, PRs welcome) 4 | 5 | tl;dr: Securely access secrets from 1Password Secrets Automation without using a bunch of 1Password Secrets Automation tokens while exploiting Tailscale ACL tags. 6 | 7 | Whew. 8 | 9 | ## Prerequisites 10 | 11 | 1. Tailscale on every relevant node with useful ACL tags denoting node-level roles 12 | 2. 1Password 13 | 3. Bloodymindedness in the dimension of not wanting to run Vault or k8s or something else sane. 14 | 15 | ## Usage 16 | 17 | 1. Set up [1Password Secrets Automation](https://developer.1password.com/docs/connect) to the point where you have your credentials file, a token, a vault, and a running connect and sync container. 18 | 2. Use their `curl` examples to note down the ID for the vault you set up 19 | 3. Create a secure note in that vault with some fields where the label is something like `DATABASE_URL` and the value is the database URL in question. Tag it, for example, `test`. 20 | 4. Run this thing, passing `OP_CONNECT_API_TOKEN` and `OP_CONNECT_VAULT_ID` as environment variables, the tailscale socket as a volume, and ensuring that it listens on a tailscale interface. 21 | 5. Ensure ACLs are set such that every other node can access the node running this proxy on port 9292 and this node can at least see every other node, even if it's not on a port bound to anything. Tag one of the other nodes `tag:test`. 22 | 5. `curl http://:9292/secrets` from the node tagged `tag:test`. You should get back something like 23 | 24 | ```json 25 | [["DATABASE_URL", "some://url"]] 26 | ``` 27 | 28 | See the included `docker-compose.yml` for how I run it in homeprod. 29 | 30 | ## Caveats 31 | 32 | * I am not affiliated with Tailscale or 1Password. 33 | * I wrote this in Ruby because that's what I reach for when I want to do something quick and dirty. 34 | * It access the Tailscale socket directly rather than going through the Tailscale golang client library. This is gross. No one at Tailscale will like this, although they seem pretty chill in general so I don't think they'll yell at me. 35 | * Caveat emptor. You almost certainly shouldn't use this in production. I sure as heck wouldn't. 36 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require './proxy' 2 | 3 | run TailscaleOPProxy.new 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | op-connect-api: 3 | image: 1password/connect-api:latest 4 | hostname: op-connect-api 5 | networks: 6 | - op-connect 7 | volumes: 8 | - "${CONFIGS_DIR}/1password-credentials.json:/home/opuser/.op/1password-credentials.json" 9 | - "data:/home/opuser/.op/data" 10 | op-connect-sync: 11 | image: 1password/connect-sync:latest 12 | hostname: op-connect-sync 13 | networks: 14 | - op-connect 15 | volumes: 16 | - "${CONFIGS_DIR}/1password-credentials.json:/home/opuser/.op/1password-credentials.json" 17 | - "data:/home/opuser/.op/data" 18 | 19 | tailscale-op-proxy: 20 | image: ghcr.io/peterkeen/tailscale-op-proxy:main 21 | environment: 22 | - OP_CONNECT_API_TOKEN 23 | - OP_CONNECT_VAULT_ID 24 | - PORT=9292 25 | - "RACK_ENV=production" 26 | ports: 27 | - "9292:9292" 28 | networks: 29 | - tailnet 30 | - op-connect 31 | volumes: 32 | - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock 33 | 34 | networks: 35 | op-connect: 36 | 37 | # docker network create -d bridge -o com.docker.network.bridge.host_binding_ipv4=$(tailscale ip | head -n1) tailnet 38 | tailnet: 39 | external: true 40 | 41 | volumes: 42 | data: 43 | -------------------------------------------------------------------------------- /proxy.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'sorbet-runtime' 3 | require 'ipaddr' 4 | require 'time' 5 | require 'json' 6 | require 'excon' 7 | require 'pp' 8 | 9 | class OPSection < T::Struct 10 | const :id, String 11 | const :label, T.nilable(String) 12 | end 13 | 14 | class OPField < T::Struct 15 | const :id, String 16 | const :type, String 17 | const :purpose, T.nilable(String) 18 | const :label, String 19 | const :value, T.nilable(String) 20 | const :section, T.nilable(OPSection) 21 | end 22 | 23 | class OPFile < T::Struct 24 | const :id, String 25 | const :name, String 26 | const :size, Integer 27 | const :content_path, String 28 | end 29 | 30 | class OPItem < T::Struct 31 | const :id, String 32 | const :title, String 33 | const :tags, T::Array[String], default: [] 34 | const :vault, T::Hash[String, String] 35 | const :category, String 36 | const :sections, T::Array[OPSection], default: [] 37 | const :fields, T::Array[OPField], default: [] 38 | const :files, T::Array[OPFile], default: [] 39 | const :createdAt, DateTime 40 | const :updatedAt, DateTime 41 | end 42 | 43 | class TailscaleOPProxy < Sinatra::Application 44 | def all_secrets 45 | conn = Excon.new('http://op-connect-api:8080', headers: {"Authorization" => "Bearer #{ENV['OP_CONNECT_API_TOKEN']}"}) 46 | response = conn.request(method: :get, path: "/v1/vaults/#{ENV['OP_CONNECT_VAULT_ID']}/items") 47 | 48 | JSON.parse(response.body).map do |item| 49 | item_resp = conn.request(method: :get, path: "/v1/vaults/#{ENV['OP_CONNECT_VAULT_ID']}/items/#{item['id']}") 50 | OPItem.from_hash(JSON.parse(item_resp.body)) 51 | end 52 | end 53 | 54 | def tags_for_server_token(token) 55 | all_secrets.flat_map do |item| 56 | next unless item.category == "SERVER" 57 | item_token = item.fields.detect { |f| f.label == "token" }&.value 58 | next unless Rack::Utils.secure_compare(token, item_token) 59 | 60 | return item.tags 61 | end 62 | 63 | nil 64 | end 65 | 66 | def secrets_for_tags(tags) 67 | tags ||= ["tag:server"] 68 | tags = tags.dup.map { |t| t.gsub(/tag:/, '') } 69 | secrets = all_secrets.select { |s| (s.tags & tags).length > 0 } 70 | 71 | secrets.flat_map do |item| 72 | item.fields.map do |field| 73 | next unless field.type == 'STRING' 74 | next if field.label =~ /notesPlain/ 75 | [field.label, field.value] 76 | end 77 | end.compact 78 | end 79 | 80 | get '/secrets' do 81 | sigil, token = env['HTTP_AUTHORIZATION']&.split(/\s+/, 2) 82 | 83 | if sigil.nil? 84 | return secrets_for_tags(nil).to_json 85 | end 86 | 87 | tags = tags_for_server_token(token) 88 | if tags.nil? 89 | halt 401, {'Content-Type' => 'application/json'}, {error: "unauthorized"}.to_json 90 | else 91 | secrets_for_tags(tags).to_json 92 | end 93 | end 94 | end 95 | --------------------------------------------------------------------------------