Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,31 @@ jobs:
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.1
- name: Install dependencies
run: bundle install
bundler-cache: true
- name: Run linter
run: bundle exec rubocop

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby-version:
- '2.7'
- '3.0'
- '3.1'
- '3.2'
- '3.3'
- '3.4'
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Run tests
run: bundle exec rspec

release-please:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
Expand All @@ -26,7 +46,7 @@ jobs:

release:
runs-on: ubuntu-latest
needs: [lint, release-please]
needs: [lint, test, release-please]
if: ${{ needs.release-please.outputs.release_created }}
steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ server/psk
.ruby-version
.idea
Gemfile.lock

# Test artifacts
spec/examples.txt
coverage/
3 changes: 3 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
--require spec_helper
--color
--format documentation
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ AllCops:
NewCops: enable
Exclude:
- lib/**/*
- vendor/**/*

Metrics/BlockLength:
Exclude:
Expand Down
132 changes: 132 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Deploy Agent is a Ruby gem that creates a secure proxy allowing DeployHQ to forward connections to protected servers. It establishes a TLS connection to DeployHQ's servers and proxies connections to allowed destinations based on an IP/network allowlist.

## Development Commands

**Install dependencies:**
```bash
bundle install
```

**Run all tests:**
```bash
bundle exec rspec
```

**Run linter:**
```bash
bundle exec rubocop
```

**Run both tests and linter:**
```bash
bundle exec rake
```

**Build gem locally:**
```bash
gem build deploy-agent.gemspec
```

**Install gem locally for testing:**
```bash
gem install ./deploy-agent-*.gem
```

**Test agent commands:**
```bash
deploy-agent setup # Configure agent with certificate and access list
deploy-agent run # Run in foreground
deploy-agent start # Start as background daemon
deploy-agent stop # Stop background daemon
deploy-agent status # Check daemon status
deploy-agent accesslist # View allowed destinations
```

## Architecture

### Core Components

The agent uses an event-driven, non-blocking I/O architecture with NIO4r for socket multiplexing:

**DeployAgent::Agent** (`lib/deploy_agent/agent.rb`)
- Main event loop using NIO::Selector and Timers::Group
- Manages lifecycle of server and destination connections
- Handles retries and error recovery
- Configures logging (STDOUT or file-based when backgrounded)

**DeployAgent::ServerConnection** (`lib/deploy_agent/server_connection.rb`)
- Maintains secure TLS connection to DeployHQ's control server (port 7777)
- Uses mutual TLS authentication with client certificates
- Processes binary protocol packets (connection requests, data transfer, keepalive, shutdown)
- Enforces destination access control via allowlist
- Manages multiple concurrent destination connections by ID

**DeployAgent::DestinationConnection** (`lib/deploy_agent/destination_connection.rb`)
- Handles non-blocking connections to backend servers (the actual deployment targets)
- Implements asynchronous connect with status tracking (:connecting, :connected)
- Bidirectional data proxying between DeployHQ and destination
- Reports connection status and errors back to ServerConnection

**DeployAgent::CLI** (`lib/deploy_agent/cli.rb`)
- Command-line interface using OptionParser
- Daemon process management (start/stop/restart/status)
- PID file management at `~/.deploy/agent.pid`

**DeployAgent::ConfigurationGenerator** (`lib/deploy_agent/configuration_generator.rb`)
- Interactive setup wizard for initial configuration
- Generates certificate via DeployHQ API
- Creates access list file with allowed destinations

### Binary Protocol

ServerConnection implements a length-prefixed binary protocol:
- Packet format: 2-byte length (network byte order) + packet data
- Packet types identified by first byte: connection request (1), connection response (2), close (3), data (4), shutdown (5), reconnect (6), keepalive (7)
- Connection IDs (2-byte unsigned) track multiple simultaneous proxied connections

### Configuration Files

All configuration stored in `~/.deploy/`:
- `agent.crt` - Client certificate for TLS authentication
- `agent.key` - Private key for client certificate
- `agent.access` - Newline-separated list of allowed IPs/networks (CIDR format)
- `agent.pid` - Process ID when running as daemon
- `agent.log` - Log file when running as daemon

### Security Model

- Mutual TLS: Both agent and server authenticate with certificates
- CA verification: Agent verifies server certificate against bundled `ca.crt`
- Allowlist enforcement: Only connections to explicitly permitted destinations are allowed
- IP/CIDR matching: Supports individual IPs and network ranges in access list

## Release Process

This project uses [release-please](https://github.com/googleapis/release-please) for automated releases. Follow [Conventional Commits](https://www.conventionalcommits.org/) specification for all commits:

- `fix:` patches (1.0.x)
- `feat:` minor versions (1.x.0)
- `!` or `BREAKING CHANGE:` major versions (x.0.0)

Example commit messages:
```text
fix: Prevent connection leak on destination timeout
feat: Add support for IPv6 destinations
feat!: Change default server port to 7778
```

## Code Style

Ruby 2.7+ syntax required. RuboCop configured with:
- Single quotes for strings
- Frozen string literals enabled
- Symbol arrays with brackets
- Empty lines around class bodies
- No documentation requirement (Style/Documentation disabled)
- `lib/` directory excluded from most metrics
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,9 @@ source 'https://rubygems.org'

gemspec

gem 'rake'
gem 'rubocop'

group :test do
gem 'rspec', '~> 3.13'
end
10 changes: 10 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
require 'rubocop/rake_task'

RSpec::Core::RakeTask.new(:spec)
RuboCop::RakeTask.new

task default: [:rubocop, :spec]
2 changes: 1 addition & 1 deletion lib/deploy_agent/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def dispatch(arguments)
opts.on('-v', '--verbose', 'Log extra debug information') do
@options[:verbose] = true
end
end.parse!
end.parse!(arguments)

if arguments[0] && methods.include?(arguments[0].to_sym)
public_send(arguments[0])
Expand Down
27 changes: 27 additions & 0 deletions spec/deploy_agent/cli_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe DeployAgent::CLI do
let(:cli) { described_class.new }

describe '#dispatch' do
it 'responds to version command' do
expect { cli.dispatch(['version']) }.to output(/\d+\.\d+\.\d+/).to_stdout
end

it 'shows usage for invalid command' do
expect { cli.dispatch(['invalid']) }.to output(/Usage: deploy-agent/).to_stdout
end

it 'shows usage when no arguments provided' do
expect { cli.dispatch([]) }.to output(/Usage: deploy-agent/).to_stdout
end
end

describe '#version' do
it 'outputs the version number' do
expect { cli.version }.to output("#{DeployAgent::VERSION}\n").to_stdout
end
end
end
13 changes: 13 additions & 0 deletions spec/deploy_agent/version_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe DeployAgent::VERSION do
it 'is defined' do
expect(DeployAgent::VERSION).not_to be_nil
end

it 'has a valid semantic version format' do
expect(DeployAgent::VERSION).to match(/\A\d+\.\d+\.\d+\z/)
end
end
68 changes: 68 additions & 0 deletions spec/deploy_agent_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe DeployAgent do
describe 'module constants' do
it 'defines CONFIG_PATH' do
expect(DeployAgent::CONFIG_PATH).to eq(File.expand_path('~/.deploy'))
end

it 'defines CERTIFICATE_PATH' do
expect(DeployAgent::CERTIFICATE_PATH).to eq(File.expand_path('~/.deploy/agent.crt'))
end

it 'defines KEY_PATH' do
expect(DeployAgent::KEY_PATH).to eq(File.expand_path('~/.deploy/agent.key'))
end

it 'defines PID_PATH' do
expect(DeployAgent::PID_PATH).to eq(File.expand_path('~/.deploy/agent.pid'))
end

it 'defines LOG_PATH' do
expect(DeployAgent::LOG_PATH).to eq(File.expand_path('~/.deploy/agent.log'))
end

it 'defines ACCESS_PATH' do
expect(DeployAgent::ACCESS_PATH).to eq(File.expand_path('~/.deploy/agent.access'))
end
end

describe '.allowed_destinations' do
let(:temp_access_file) { "/tmp/test_access_#{Process.pid}" }

before do
stub_const('DeployAgent::ACCESS_PATH', temp_access_file)
end

after do
FileUtils.rm_f(temp_access_file)
end

it 'reads destinations from access file' do
File.write(temp_access_file, "127.0.0.1\n192.168.1.0/24\n")
expect(DeployAgent.allowed_destinations).to eq(['127.0.0.1', '192.168.1.0/24'])
end

it 'strips whitespace from destinations' do
File.write(temp_access_file, " 127.0.0.1 \n 192.168.1.1 \n")
expect(DeployAgent.allowed_destinations).to eq(['127.0.0.1', '192.168.1.1'])
end

it 'ignores empty lines' do
File.write(temp_access_file, "127.0.0.1\n\n192.168.1.1\n")
expect(DeployAgent.allowed_destinations).to eq(['127.0.0.1', '192.168.1.1'])
end

it 'ignores comment lines' do
File.write(temp_access_file, "# Comment\n127.0.0.1\n# Another comment\n192.168.1.1\n")
expect(DeployAgent.allowed_destinations).to eq(['127.0.0.1', '192.168.1.1'])
end

it 'extracts only the first field from each line' do
File.write(temp_access_file, "127.0.0.1 localhost\n192.168.1.1 description\n")
expect(DeployAgent.allowed_destinations).to eq(['127.0.0.1', '192.168.1.1'])
end
end
end
24 changes: 24 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require 'deploy_agent'

RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end

config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true
end

config.shared_context_metadata_behavior = :apply_to_host_groups
config.filter_run_when_matching :focus
config.example_status_persistence_file_path = 'spec/examples.txt'
config.disable_monkey_patching!
config.warnings = true

config.default_formatter = 'doc' if config.files_to_run.one?

config.order = :random
Kernel.srand config.seed
end