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/.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: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2011225 --- /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: +```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 \ 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