From bd59b61a5dd1372a8abd71f6029a37412a21e60b Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 13 Nov 2025 15:34:43 +0100 Subject: [PATCH 1/3] feat: Add RSpec test suite and multi-version Ruby CI support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive RSpec test suite with 17 test cases covering: - Module constants validation - allowed_destinations parser (comments, whitespace, CIDR) - Version format validation - CLI command dispatch and output - Update CI workflow to test against Ruby 2.7, 3.0, 3.1, 3.2, 3.3, 3.4 - Add Rakefile with default task running rubocop and rspec - Add bundler-cache to CI for faster builds - Fix CLI#dispatch to parse arguments array instead of global ARGV - Add CLAUDE.md with project architecture and development guidance - Update .gitignore for test artifacts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 26 +++++- .gitignore | 4 + .rspec | 3 + CLAUDE.md | 132 ++++++++++++++++++++++++++++++ Gemfile | 5 ++ Rakefile | 10 +++ lib/deploy_agent/cli.rb | 2 +- spec/deploy_agent/cli_spec.rb | 27 ++++++ spec/deploy_agent/version_spec.rb | 13 +++ spec/deploy_agent_spec.rb | 68 +++++++++++++++ spec/spec_helper.rb | 24 ++++++ 11 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 .rspec create mode 100644 CLAUDE.md create mode 100644 Rakefile create mode 100644 spec/deploy_agent/cli_spec.rb create mode 100644 spec/deploy_agent/version_spec.rb create mode 100644 spec/deploy_agent_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 154e760..8aaa016 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' @@ -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 diff --git a/.gitignore b/.gitignore index c91f267..2178455 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ server/psk .ruby-version .idea Gemfile.lock + +# Test artifacts +spec/examples.txt +coverage/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..f6f85f5 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--color +--format documentation \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9b9d521 --- /dev/null +++ b/CLAUDE.md @@ -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: +``` +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 \ No newline at end of file diff --git a/Gemfile b/Gemfile index b81a861..6042f41 100644 --- a/Gemfile +++ b/Gemfile @@ -4,4 +4,9 @@ source 'https://rubygems.org' gemspec +gem 'rake' gem 'rubocop' + +group :test do + gem 'rspec', '~> 3.13' +end diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..fbf2826 --- /dev/null +++ b/Rakefile @@ -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] diff --git a/lib/deploy_agent/cli.rb b/lib/deploy_agent/cli.rb index c55db06..23f7eaf 100644 --- a/lib/deploy_agent/cli.rb +++ b/lib/deploy_agent/cli.rb @@ -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]) diff --git a/spec/deploy_agent/cli_spec.rb b/spec/deploy_agent/cli_spec.rb new file mode 100644 index 0000000..15093d8 --- /dev/null +++ b/spec/deploy_agent/cli_spec.rb @@ -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 diff --git a/spec/deploy_agent/version_spec.rb b/spec/deploy_agent/version_spec.rb new file mode 100644 index 0000000..6a7e83e --- /dev/null +++ b/spec/deploy_agent/version_spec.rb @@ -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 diff --git a/spec/deploy_agent_spec.rb b/spec/deploy_agent_spec.rb new file mode 100644 index 0000000..60ec55e --- /dev/null +++ b/spec/deploy_agent_spec.rb @@ -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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..b521bcb --- /dev/null +++ b/spec/spec_helper.rb @@ -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 From b12a7cbb270e631e8efb266f05372fe430bb25d3 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 13 Nov 2025 15:38:01 +0100 Subject: [PATCH 2/3] fix: Exclude vendor directory from RuboCop inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents RuboCop from scanning vendored gems with obsolete configurations that cause CI failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .rubocop.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.rubocop.yml b/.rubocop.yml index 90e9b06..9c2a7e0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,7 @@ AllCops: NewCops: enable Exclude: - lib/**/* + - vendor/**/* Metrics/BlockLength: Exclude: From 369fe90eeabf5438c1a591e2d24568740f4fbcf4 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 13 Nov 2025 15:45:30 +0100 Subject: [PATCH 3/3] docs: Add language identifier to code block in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b9d521..2011225 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ This project uses [release-please](https://github.com/googleapis/release-please) - `!` 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