diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f37aea --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Created by https://www.toptal.com/developers/gitignore/api/ruby +# Edit at https://www.toptal.com/developers/gitignore?templates=ruby + +### Ruby ### +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ + +# Used by dotenv library to load environment variables. +# .env + +# Ignore Byebug command history file. +.byebug_history + +## Specific to RubyMotion: +.dat* +.repl_history +build/ +*.bridgesupport +build-iPhoneOS/ +build-iPhoneSimulator/ + +## Specific to RubyMotion (use of CocoaPods): +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# vendor/Pods/ + +## Documentation cache and generated files: +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ + +## Environment normalization: +/.bundle/ +/vendor/bundle +/lib/bundler/man/ + +# for a library or gem, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +Gemfile.lock +.ruby-version +.ruby-gemset + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc + +# Used by RuboCop. Remote config files pulled in from inherit_from directive. +# .rubocop-https?--* + +# End of https://www.toptal.com/developers/gitignore/api/ruby diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..ec666a3 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,24 @@ +--- +require: + - rubocop-rspec + - rubocop-rake + - rubocop-performance + +AllCops: + NewCops: enable + +Style/NegatedIf: + Enabled: false + +Metrics/MethodLength: + CountAsOne: + - 'array' + - 'hash' + - 'heredoc' + Max: 25 + +Metrics/AbcSize: + CountRepeatedAttributes: false + +Style/Documentation: + Enabled: false diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..134e504 --- /dev/null +++ b/Gemfile @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' +gemspec + +group :development, :test do + gem 'rake' + + gem 'pry' + gem 'pry-byebug' + gem 'pry-doc' + gem 'pry-rescue' + + gem 'rspec' + + gem 'rubocop' + gem 'rubocop-performance' + gem 'rubocop-rake' + gem 'rubocop-rspec' + + gem 'simplecov' +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..45033cc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Tobias Schäfer + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..84b23b0 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# tls-ping + +TLS ping host and port. + +## Introduction + +**tls-ping** connects to a given host and port and validates the TLS connection +and certificate. + +## Installation + +```bash +gem build +version=$(ruby -Ilib -e 'require "tls/ping"; puts TLS::Ping::VERSION') +gem install tls-ping-${version}.gem +``` + +## Usage + +```bash +$ tls-ping github.com 443 +> github.com:443 + [ OK ] /CN=github.com +``` + +For further information about the command line tool `tls-ping` see the following +help output. + +```bash +Usage: + tls-ping [OPTIONS] HOST PORT + +Parameters: + HOST hostname to ping + PORT port to ping + +Options: + -s, --starttls use STARTTLS + -t, --timeout SECONDS timeout in seconds (default: 5) + -q, --quiet suppress output + -h, --help print help + -m, --man show manpage + -v, --version show version +``` + +## License + +[MIT License](https://spdx.org/licenses/MIT.html) + +## Is it any good? + +[Yes.](https://news.ycombinator.com/item?id=3067434) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b39d0aa --- /dev/null +++ b/Rakefile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' +require 'rubocop/rake_task' + +FileList['tasks/**/*.rake'].each { |f| import(f) } + +RSpec::Core::RakeTask.new(:rspec) +RuboCop::RakeTask.new + +desc "Run tasks 'rubocop' by default." +task default: %w[rubocop] diff --git a/bin/tls-ping b/bin/tls-ping new file mode 100755 index 0000000..adf039a --- /dev/null +++ b/bin/tls-ping @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'tls/ping/app' + +TLS::Ping::App::Command.run diff --git a/lib/tls.rb b/lib/tls.rb new file mode 100644 index 0000000..6ba2b93 --- /dev/null +++ b/lib/tls.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require_relative 'tls/ping' + +# :nodoc: +module TLS + class << self + def ping(...) + TLS::Ping.new(...).succeeded! + end + end +end diff --git a/lib/tls/ping.rb b/lib/tls/ping.rb new file mode 100644 index 0000000..75c1c97 --- /dev/null +++ b/lib/tls/ping.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'openssl' +require 'socket' +require 'timeout' + +module TLS + class Ping + VERSION = '0.1.0' + + attr_reader :error, :peer_cert + + def initialize(host, port, starttls: false, timeout: 5) + @host = host + @port = port + @starttls = starttls + @timeout = timeout + + execute + end + + def succeeded? + @error.nil? + end + + def succeeded! + raise @error if @error + end + + private + + def execute + socket = Timeout.timeout(@timeout) do + socket = TCPSocket.new(@host, @port) + socket.timeout = @timeout + socket + end + + starttls(socket) if @starttls + + tls_socket = OpenSSL::SSL::SSLSocket.new(socket, tls_ctx) + tls_socket.hostname = @host + tls_socket.connect + rescue StandardError => e + @error = e + ensure + @peer_cert = tls_socket&.peer_cert || tls_socket&.peer_cert_chain&.first + tls_socket&.close + socket&.close + end + + def tls_ctx + OpenSSL::SSL::SSLContext.new.tap do |ctx| + store = OpenSSL::X509::Store.new + store.set_default_paths + + ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + ctx.cert_store = store + ctx.timeout = @timeout + end + end + + def starttls(socket) + return if !@starttls + + socket.gets + socket.write("EHLO tls.ping\r\n") + + loop do + break if socket.gets.start_with?('250 ') + end + + socket.write("STARTTLS\r\n") + socket.gets + end + end +end diff --git a/lib/tls/ping/app.rb b/lib/tls/ping/app.rb new file mode 100644 index 0000000..4dae629 --- /dev/null +++ b/lib/tls/ping/app.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative 'app/base' +require_relative '../../tls' + +module TLS + class Ping + module App + class Command < TLS::Ping::App::BaseCommand + parameter 'HOST', 'hostname to ping' + parameter 'PORT', 'port to ping' + option ['-s', '--starttls'], :flag, 'use STARTTLS' + option ['-t', '--timeout'], 'SECONDS', 'timeout in seconds', default: 5 + option ['-q', '--quiet'], :flag, 'suppress output' + + PING_OK = 0 + PING_FAIL = 1 + PING_UNKNOWN = 255 + + def execute + header + code, reason = action + result(code, reason:) + + exit(code) + end + + private + + def header + return if quiet? + + puts "> #{host}:#{port}" if !quiet? + end + + def action + ping = TLS::Ping.new( + host, + port, + starttls: starttls?, + timeout: timeout.to_f + ) + ping.succeeded! + + reason = ping.peer_cert.subject.to_s + [PING_OK, reason] + rescue OpenSSL::SSL::SSLError => e + reason = e.message.split(': ').last.capitalize + [PING_FAIL, reason] + rescue StandardError + [PING_UNKNOWN] + end + + def result(code, reason: nil) + return if quiet? + + status = { + PING_OK => Pastel.new.green.bold('OK'), + PING_FAIL => Pastel.new.red.bold('FAIL'), + PING_UNKNOWN => Pastel.new.yellow.bold('UNKNOWN') + }[code] + + info = " [ #{status} ]" + info += " #{reason}" if reason + + puts info + end + end + end + end +end diff --git a/lib/tls/ping/app/base.rb b/lib/tls/ping/app/base.rb new file mode 100644 index 0000000..8fd4840 --- /dev/null +++ b/lib/tls/ping/app/base.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'clamp' +require 'pastel' +require 'tty-pager' + +require_relative '../../ping' + +module TLS + class Ping + module App + class BaseCommand < Clamp::Command + option ['-m', '--man'], :flag, 'show manpage' do # rubocop:disable Metrics/BlockLength + manpage = <<~MANPAGE + Name: + tls-ping - TLS ping host and port + + #{help} + Description: + tls-ping connects to a given host and port and validates the + TLS connection and certificate. + + tls-ping prompts an informational message and exits with one of + following status code. + + Status codes: + 0 - ping succeeded + 1 - ping failed + 255 - unknown error + + Examples: + $ tls-ping badssl.com 443 + > badssl.com:443 + [ OK ] /CN=*.badssl.com + + $ tls-ping badssl.com 80 + > badsll.com:80 + [ FAIL ] Wrong version number + + $ tls-ping self-signed.badssl.com 443 + > self-signed.badssl.com:443 + [ FAIL ] Certificate verify failed (self-signed certificate) + + $ tls-ping badssl.com 22 + > badssl.com:22 + [ UNKNOWN ] + + $ tls-ping --starttls smtp.gmail.com 25 + > smtp.gmail.com:25 + [ OK ] /CN=smtp.gmail.com + + Authors: + Tobias Schäfer + + Copyright and License + This software is copyright (c) 2024 by Tobias Schäfer. + + This package is free software; you can redistribute it and/or + modify it under the terms of the "GPLv3.0 License". + MANPAGE + TTY::Pager.page(manpage) + + exit 0 + end + + option ['-v', '--version'], :flag, 'show version' do + puts "tls-ping #{TLS::Ping::VERSION} - All your certificate are belong to us!" + exit(0) + end + end + end + end +end diff --git a/spec/lib/tls/ping_spec.rb b/spec/lib/tls/ping_spec.rb new file mode 100644 index 0000000..8565edb --- /dev/null +++ b/spec/lib/tls/ping_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'tls/ping' + +RSpec.describe TLS::Ping do + describe '#succeeded?' do + shared_examples 'a verified connection' do + it 'has succeded' do + expect(connection).to be_succeeded + end + end + + shared_examples 'an unverified connection' do + it 'has failed' do + expect(connection).not_to be_succeeded + end + end + + context 'with a valid TLS connection' do + let(:connection) { described_class.new('example.com', 443) } + + it_behaves_like 'a verified connection' + end + + context 'with an invalid TLS connection' do + let(:connection) { described_class.new('example.com', 80) } + + it_behaves_like 'an unverified connection' + end + + context 'with a valid STARTTLS connection' do + let(:connection) { described_class.new('smtp.gmail.com', 25, starttls: true) } + + it_behaves_like 'a verified connection' + end + + context 'with an invalid STARTTLS connection' do + let(:connection) { described_class.new('smtp.gmail.com', 465, starttls: true) } + + it_behaves_like 'an unverified connection' + end + + context 'with a expired certificate' do + let(:connection) { described_class.new('expired.badssl.com', 443) } + + it_behaves_like 'an unverified connection' + end + + context 'with missing issuer certificate' do + let(:connection) { described_class.new('missing.badssl.com', 443) } + + it_behaves_like 'an unverified connection' + end + end + + describe '#succeeded!' do + context 'with a valid TLS connection' do + it 'does not raise an error' do + expect { described_class.new('example.com', 443).succeeded! }.not_to raise_error + end + end + + context 'with an invalid TLS connection' do + it 'raises an error' do + expect { described_class.new('example.com', 80).succeeded! }.to raise_error(StandardError) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..70f8534 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration + +require 'pry' +require 'pry-byebug' +require 'pry-doc' +require 'pry-rescue' + +# Environment variable COVERAGE enables test coverage. +if ENV['COVERAGE'] + require 'simplecov' + + SimpleCov.start +end + +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = 'spec/examples.txt' + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + # config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = 'doc' + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + # config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed + + # report every failed expectation + config.define_derived_metadata do |meta| + meta[:aggregate_failures] = true unless meta.key?(:aggregate_failures) + end +end diff --git a/tasks/console.rake b/tasks/console.rake new file mode 100644 index 0000000..af1b59f --- /dev/null +++ b/tasks/console.rake @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +ROOT = "#{File.dirname(__FILE__)}/..".freeze + +def silent + original_stdout = $stdout.clone + original_stderr = $stderr.clone + $stderr.reopen File.new(File::NULL, 'w') + $stdout.reopen File.new(File::NULL, 'w') + yield +ensure + $stdout.reopen original_stdout + $stderr.reopen original_stderr +end + +def reload!(print: true) + puts 'Reloading...' if print + reload_dirs = %w[lib] + reload_dirs.each do |dir| + Dir.glob("#{ROOT}/#{dir}/**/*.rb").each { |f| silent { load(f) } } + end + + true +end + +desc 'Start a console session with TLS::Ping loaded' +task :console do + require 'pry' + require 'pry-doc' + require 'pry-theme' + require 'tls' + require 'tls/ping' + + ARGV.clear + + Pry.start +end diff --git a/tls-ping.gemspec b/tls-ping.gemspec new file mode 100644 index 0000000..fc263d0 --- /dev/null +++ b/tls-ping.gemspec @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +$LOAD_PATH << File.expand_path('lib', __dir__) +require 'tls/ping' + +Gem::Specification.new do |spec| + spec.name = 'tls-ping' + spec.version = TLS::Ping::VERSION + spec.platform = Gem::Platform::RUBY + spec.required_ruby_version = '>= 3.3.0' + spec.authors = ['Tobias Schäfer'] + spec.email = ['github@blackox.org'] + + spec.summary = 'Ping TLS host and port.' + spec.description = <<~DESC + #{spec.summary} + DESC + spec.homepage = 'https://github.com/tschaefer/tls-ping' + spec.license = 'MIT' + + spec.files = Dir['lib/**/*'] + spec.bindir = 'bin' + spec.executables = ['tls-ping'] + spec.require_paths = ['lib'] + + spec.metadata['rubygems_mfa_required'] = 'true' + spec.metadata['source_code_uri'] = 'https://github.com/tschaefer/tls-ping' + spec.metadata['bug_tracker_uri'] = 'https://github.com/tschaefer/tls-ping/issues' + + spec.post_install_message = 'All your certificate are belong to us!' + + spec.add_dependency 'clamp', '~> 1.3.2' + spec.add_dependency 'pastel', '~> 0.8.0' + spec.add_dependency 'tty-pager', '~> 0.14.0' +end