pax_global_header00006660000000000000000000000064151310210330014477gustar00rootroot0000000000000052 comment=767eda049bd46d34a9bdb40d1ca95d9197042b22 socketry-localhost-767eda0/000077500000000000000000000000001513102103300157475ustar00rootroot00000000000000socketry-localhost-767eda0/.editorconfig000066400000000000000000000001511513102103300204210ustar00rootroot00000000000000root = true [*] indent_style = tab indent_size = 2 [*.{yml,yaml}] indent_style = space indent_size = 2 socketry-localhost-767eda0/.github/000077500000000000000000000000001513102103300173075ustar00rootroot00000000000000socketry-localhost-767eda0/.github/copilot-instructions.md000066400000000000000000000016431513102103300240500ustar00rootroot00000000000000# GitHub Copilot Instructions ## Use Agent Context When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. If the file does not exist, you will need to install it, by running the following command: ```bash $ bundle install $ bundle exec bake agent:context:install ``` This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. ## Ignoring Files The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. socketry-localhost-767eda0/.github/workflows/000077500000000000000000000000001513102103300213445ustar00rootroot00000000000000socketry-localhost-767eda0/.github/workflows/documentation-coverage.yaml000066400000000000000000000006511513102103300266740ustar00rootroot00000000000000name: Documentation Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Validate coverage timeout-minutes: 5 run: bundle exec bake decode:index:coverage lib socketry-localhost-767eda0/.github/workflows/documentation.yaml000066400000000000000000000021311513102103300250760ustar00rootroot00000000000000name: Documentation on: push: branches: - main # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: permissions: contents: read pages: write id-token: write # Allow one concurrent deployment: concurrency: group: "pages" cancel-in-progress: true env: BUNDLE_WITH: maintenance jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Installing packages run: sudo apt-get install wget - name: Generate documentation timeout-minutes: 5 run: bundle exec bake utopia:project:static --force no - name: Upload documentation artifact uses: actions/upload-pages-artifact@v4 with: path: docs deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{steps.deployment.outputs.page_url}} needs: generate steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 socketry-localhost-767eda0/.github/workflows/rubocop.yaml000066400000000000000000000005321513102103300237010ustar00rootroot00000000000000name: RuboCop on: [push, pull_request] permissions: contents: read jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Run RuboCop timeout-minutes: 10 run: bundle exec rubocop socketry-localhost-767eda0/.github/workflows/test-coverage.yaml000066400000000000000000000022031513102103300247750ustar00rootroot00000000000000name: Test Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - ruby steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 5 run: bundle exec bake test - uses: actions/upload-artifact@v5 with: include-hidden-files: true if-no-files-found: error name: coverage-${{matrix.os}}-${{matrix.ruby}} path: .covered.db validate: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - uses: actions/download-artifact@v6 - name: Validate coverage timeout-minutes: 5 run: bundle exec bake covered:validate --paths */.covered.db \; socketry-localhost-767eda0/.github/workflows/test-external.yaml000066400000000000000000000012121513102103300250230ustar00rootroot00000000000000name: Test External on: [push, pull_request] permissions: contents: read jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - "3.2" - "3.3" - "3.4" - "4.0" steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby-pkgs@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true apt-get: ragel brew: ragel - name: Run tests timeout-minutes: 10 run: bundle exec bake test:external socketry-localhost-767eda0/.github/workflows/test.yaml000066400000000000000000000016501513102103300232110ustar00rootroot00000000000000name: Test on: [push, pull_request] permissions: contents: read jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest continue-on-error: ${{matrix.experimental}} strategy: matrix: os: - ubuntu - macos ruby: - "3.2" - "3.3" - "3.4" - "4.0" experimental: [false] include: - os: ubuntu ruby: truffleruby experimental: true - os: ubuntu ruby: jruby experimental: true - os: ubuntu ruby: head experimental: true steps: - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 10 run: bundle exec bake test socketry-localhost-767eda0/.gitignore000066400000000000000000000001741513102103300177410ustar00rootroot00000000000000/agents.md /.context /.bundle /pkg /gems.locked /.covered.db /external # Used as default state directory for tests: /state socketry-localhost-767eda0/.mailmap000066400000000000000000000001671513102103300173740ustar00rootroot00000000000000Juri Hahn Yuuji Yaginuma Colin Shea <14547+nogweii@users.noreply.github.com> socketry-localhost-767eda0/.rubocop.yml000066400000000000000000000035401513102103300202230ustar00rootroot00000000000000plugins: - rubocop-md - rubocop-socketry AllCops: DisabledByDefault: true # Socketry specific rules: Layout/ConsistentBlankLineIndentation: Enabled: true Layout/BlockDelimiterSpacing: Enabled: true Style/GlobalExceptionVariables: Enabled: true # General Layout rules: Layout/IndentationStyle: Enabled: true EnforcedStyle: tabs Layout/InitialIndentation: Enabled: true Layout/IndentationWidth: Enabled: true Width: 1 Layout/IndentationConsistency: Enabled: true EnforcedStyle: normal Layout/BlockAlignment: Enabled: true Layout/EndAlignment: Enabled: true EnforcedStyleAlignWith: start_of_line Layout/BeginEndAlignment: Enabled: true EnforcedStyleAlignWith: start_of_line Layout/RescueEnsureAlignment: Enabled: true Layout/ElseAlignment: Enabled: true Layout/DefEndAlignment: Enabled: true Layout/CaseIndentation: Enabled: true EnforcedStyle: end Layout/CommentIndentation: Enabled: true Layout/FirstHashElementIndentation: Enabled: true EnforcedStyle: consistent Layout/EmptyLinesAroundClassBody: Enabled: true Layout/EmptyLinesAroundModuleBody: Enabled: true Layout/EmptyLineAfterMagicComment: Enabled: true Layout/SpaceInsideBlockBraces: Enabled: true EnforcedStyle: no_space SpaceBeforeBlockParameters: false Layout/SpaceAroundBlockParameters: Enabled: true EnforcedStyleInsidePipes: no_space Layout/FirstArrayElementIndentation: Enabled: true EnforcedStyle: consistent Layout/ArrayAlignment: Enabled: true EnforcedStyle: with_fixed_indentation Layout/FirstArgumentIndentation: Enabled: true EnforcedStyle: consistent Layout/ArgumentAlignment: Enabled: true EnforcedStyle: with_fixed_indentation Layout/ClosingParenthesisIndentation: Enabled: true Style/FrozenStringLiteralComment: Enabled: true Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes socketry-localhost-767eda0/bake.rb000066400000000000000000000005561513102103300172040ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. # Update the project documentation with the new version number. # # @parameter version [String] The new version number. def after_gem_release_version_increment(version) context["releases:update"].call(version) context["utopia:project:readme:update"].call end socketry-localhost-767eda0/bake/000077500000000000000000000000001513102103300166515ustar00rootroot00000000000000socketry-localhost-767eda0/bake/localhost.rb000066400000000000000000000021231513102103300211640ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2025, by Samuel Williams. def initialize(...) super require_relative "../lib/localhost" end # List all local authorities. # # @returns [Array(Hash)] The certificate and key paths, and the expiry date. def list Localhost::Authority.list.map(&:to_h) end # Fetch a local authority by hostname. If the authority does not exist, it will be created. # # @parameter hostname [String] The hostname to fetch. # @returns [Hash] The certificate and key paths, and the expiry date. def fetch(hostname) Localhost::Authority.fetch(hostname) end # Install a certificate into the system trust store. # @parameter name [String] The name of the issuer to install, or nil for the default issuer. def install(name: nil) issuer = Localhost::Issuer.fetch(name) $stderr.puts "Installing certificate for #{issuer.subject}..." Localhost::System.current.install(issuer.certificate_path) return nil end # Delete all local authorities. def purge $stderr.puts "Purging localhost state..." Localhost::State.purge return nil end socketry-localhost-767eda0/config/000077500000000000000000000000001513102103300172145ustar00rootroot00000000000000socketry-localhost-767eda0/config/external.yaml000066400000000000000000000003221513102103300217170ustar00rootroot00000000000000falcon: url: https://github.com/socketry/falcon command: bundle exec bake test puma: url: https://github.com/puma/puma command: bundle exec rake compile; test/runner -v test_puma_localhost_authority.rb socketry-localhost-767eda0/config/sus.rb000066400000000000000000000002311513102103300203470ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "covered/sus" include Covered::Sus socketry-localhost-767eda0/gems.rb000066400000000000000000000010051513102103300172230ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2026, by Samuel Williams. source "https://rubygems.org" gemspec group :maintenance, optional: true do gem "bake-gem" gem "bake-modernize" gem "bake-releases" gem "utopia-project" end group :test do gem "sus" gem "covered" gem "decode" gem "rubocop" gem "rubocop-md" gem "rubocop-socketry" gem "io-endpoint" gem "sus-fixtures-async" gem "sus-fixtures-async-http" gem "bake-test" gem "bake-test-external" end socketry-localhost-767eda0/guides/000077500000000000000000000000001513102103300172275ustar00rootroot00000000000000socketry-localhost-767eda0/guides/example-server/000077500000000000000000000000001513102103300221665ustar00rootroot00000000000000socketry-localhost-767eda0/guides/example-server/https.rb000077500000000000000000000026641513102103300236700ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. # Copyright, 2021, by Ye Lin Aung. # Require the required libraries: require "async" require "async/io/host_endpoint" require "async/io/ssl_endpoint" require "async/http/server" require "async/http/client" require "localhost" # The (self-signed) authority to use: hostname = "localhost" authority = Localhost::Authority.fetch(hostname) # The server app: app = lambda do |request| Protocol::HTTP::Response[200, {}, ["Hello World"]] end # Bind to the specified host: endpoint = Async::IO::Endpoint.tcp(hostname, "8080") # Prepare the server, endpoint will be used for `bind`: server_endpoint = Async::IO::SSLEndpoint.new(endpoint, ssl_context: authority.server_context) server = Async::HTTP::Server.new(app, server_endpoint, protocol: Async::HTTP::Protocol::HTTP1, scheme: "https") # Prepare the client, endpoint will be used for `connect`: client_endpoint = Async::IO::SSLEndpoint.new(endpoint, ssl_context: authority.client_context) client = Async::HTTP::Client.new(client_endpoint, protocol: Async::HTTP::Protocol::HTTP1, scheme: "https", authority: authority) # Run the reactor: Async do |task| # Start the server task: server_task = task.async do server.run end # Connect to the server: response = client.get("/") puts "Status: #{response.status}\n#{response.read}" # Stop the server: server_task.stop end socketry-localhost-767eda0/guides/example-server/readme.md000066400000000000000000000001761513102103300237510ustar00rootroot00000000000000# Example Server This guide demonstrates how to use {ruby Localhost::Authority} to implement a simple HTTPS client & server. socketry-localhost-767eda0/guides/getting-started/000077500000000000000000000000001513102103300223345ustar00rootroot00000000000000socketry-localhost-767eda0/guides/getting-started/readme.md000066400000000000000000000046241513102103300241210ustar00rootroot00000000000000# Getting Started This guide explains how to use `localhost` for provisioning local TLS certificates for development. ## Installation Add the gem to your project: ~~~ bash $ bundle add localhost ~~~ Then, generate an issuer certificate and install it: ~~~ bash $ bundle exec bake localhost:install ~~~ You may be prompted for a password to install the certificate. This is the password for your local keychain. ### Purging your certificates If you have an existing installation which does not use the issuer certificate, you can remove the existing certificates and start over: ~~~ bash $ bundle exec bake localhost:purge ~~~ Note this will remove all certificates in the `$XDG_STATE_HOME/localhost.rb/` directory, but it won't remove the issuer certificate that was installed in your keychain. ## Core Concepts `localhost` has two core concepts: - A {ruby Localhost::Issuer} instance which represents a certificate authority (CA) that can be used to sign certificates for localhost. - A {ruby Localhost::Authority} instance which represents a public and private key pair that can be used for both clients and servers. ### Files The certificate and private key are stored in `$XDG_STATE_HOME/localhost.rb/` (typically `~/.local/state/localhost.rb/`). You can delete them and they will be regenerated. If you added the certificate to your computer's certificate store/keychain, you'll you'd need to update it. ## Usage In general, you won't need to do anything at all. The application server you are using will automatically provision a self-signed certificate for localhost. That being said, if you want to implement your own self-signed secure server, the following example demonstrates how to use the {ruby Localhost::Authority}: ``` ruby require "socket" require "thread" require "localhost/authority" # Get the self-signed authority for localhost: authority = Localhost::Authority.fetch ready = Thread::Queue.new # Start a server thread: server_thread = Thread.new do server = OpenSSL::SSL::SSLServer.new(TCPServer.new("localhost", 4050), authority.server_context) server.listen ready << true peer = server.accept peer.puts "Hello World!" peer.flush peer.close end ready.pop client = OpenSSL::SSL::SSLSocket.new(TCPSocket.new("localhost", 4050), authority.client_context) # Initialize SSL connection: client.connect # Read the encrypted message: puts client.read(12) client.close server_thread.join ``` socketry-localhost-767eda0/guides/links.yaml000066400000000000000000000000661513102103300212350ustar00rootroot00000000000000getting-started: order: 1 example-server: order: 2socketry-localhost-767eda0/lib/000077500000000000000000000000001513102103300165155ustar00rootroot00000000000000socketry-localhost-767eda0/lib/localhost.rb000066400000000000000000000004011513102103300210250ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. require_relative "localhost/version" require_relative "localhost/authority" require_relative "localhost/system" # @namespace module Localhost end socketry-localhost-767eda0/lib/localhost/000077500000000000000000000000001513102103300205055ustar00rootroot00000000000000socketry-localhost-767eda0/lib/localhost/authority.rb000066400000000000000000000165251513102103300230730ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2026, by Samuel Williams. # Copyright, 2019, by Richard S. Leung. # Copyright, 2021, by Akshay Birajdar. # Copyright, 2021, by Ye Lin Aung. # Copyright, 2023, by Antonio Terceiro. # Copyright, 2023, by Yuuji Yaginuma. # Copyright, 2024, by Colin Shea. # Copyright, 2024, by Aurel Branzeanu. require "fileutils" require "openssl" require_relative "state" require_relative "issuer" module Localhost # Represents a single public/private key pair for a given hostname. class Authority # @returns [String] The path to the directory containing the certificate authorities. def self.path State.path end # List all certificate authorities in the given directory. # # @parameter path [String] The path to the directory containing the certificate authorities. # @yields [Authority] Each certificate authority in the directory. def self.list(path = State.path) return to_enum(:list, path) unless block_given? Dir.glob("*.crt", base: path) do |certificate_path| hostname = File.basename(certificate_path, ".crt") authority = self.new(hostname, path: path) if authority.load yield authority end end end # Fetch (load or create) a certificate with the given hostname. # See {#initialize} for the format of the arguments. def self.fetch(*arguments, **options) authority = self.new(*arguments, **options) unless authority.load authority.save end return authority end # Create an authority forn the given hostname. # @parameter hostname [String] The common name to use for the certificate. # @parameter path [String] The path path for loading and saving the certificate. def initialize(hostname = "localhost", path: State.path, issuer: Issuer.fetch) @path = path @hostname = hostname @issuer = issuer @subject = nil @key = nil @certificate = nil @store = nil end attr :issuer # The hostname of the certificate authority. attr :hostname BITS = 1024*2 # @returns [OpenSSL::PKey::DH] A Diffie-Hellman key suitable for secure key exchange. def dh_key @dh_key ||= OpenSSL::PKey::DH.new(BITS) end # @returns [String] The path to the private key. def key_path File.join(@path, "#{@hostname}.key") end # @returns [String] The path to the public certificate. def certificate_path File.join(@path, "#{@hostname}.crt") end # @returns [OpenSSL::PKey::RSA] The private key. def key @key ||= OpenSSL::PKey::RSA.new(BITS) end # Set the private key. # # @parameter key [OpenSSL::PKey::RSA] The private key. def key= key @key = key end # @returns [OpenSSL::X509::Name] The subject name for the certificate. def subject @subject ||= OpenSSL::X509::Name.parse("/O=localhost.rb/CN=#{@hostname}") end # Set the subject name for the certificate. # # @parameter subject [OpenSSL::X509::Name] The subject name. def subject= subject @subject = subject end # Generates a self-signed certificate if one does not already exist for the given hostname. # # @returns [OpenSSL::X509::Certificate] A self-signed certificate. def certificate issuer = @issuer || self @certificate ||= OpenSSL::X509::Certificate.new.tap do |certificate| certificate.subject = self.subject certificate.issuer = issuer.subject certificate.public_key = self.key.public_key certificate.serial = Time.now.to_i certificate.version = 2 certificate.not_before = Time.now certificate.not_after = Time.now + (3600 * 24 * 365) extension_factory = OpenSSL::X509::ExtensionFactory.new extension_factory.subject_certificate = certificate extension_factory.issuer_certificate = @issuer&.certificate || certificate certificate.add_extension extension_factory.create_extension("basicConstraints", "CA:FALSE", true) certificate.add_extension extension_factory.create_extension("subjectKeyIdentifier", "hash") certificate.add_extension extension_factory.create_extension("subjectAltName", "DNS: #{@hostname}") certificate.add_extension extension_factory.create_extension("authorityKeyIdentifier", "keyid:always,issuer:always") certificate.sign issuer.key, OpenSSL::Digest::SHA256.new end end # The certificate store which is used for validating the server certificate. # # @returns [OpenSSL::X509::Store] The certificate store with the issuer certificate. def store @store ||= OpenSSL::X509::Store.new.tap do |store| if @issuer store.add_cert(@issuer.certificate) else store.add_cert(self.certificate) end end end SERVER_CIPHERS = "EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5".freeze # @returns [OpenSSL::SSL::SSLContext] An context suitable for implementing a secure server. def server_context(*arguments) OpenSSL::SSL::SSLContext.new(*arguments).tap do |context| context.key = self.key context.cert = self.certificate if @issuer context.extra_chain_cert = [@issuer.certificate] end context.session_id_context = "localhost" if context.respond_to? :tmp_dh_callback= context.tmp_dh_callback = proc{self.dh_key} end if context.respond_to? :ecdh_curves= context.ecdh_curves = "P-256:P-384:P-521" end context.set_params( ciphers: SERVER_CIPHERS, verify_mode: OpenSSL::SSL::VERIFY_NONE, ) end end # @returns [OpenSSL::SSL::SSLContext] An context suitable for connecting to a secure server using this authority. def client_context(*args) OpenSSL::SSL::SSLContext.new(*args).tap do |context| context.cert_store = self.store context.set_params( verify_mode: OpenSSL::SSL::VERIFY_PEER, ) end end # Load the certificate and key from the given path. # # @parameter path [String] The path to the certificate and key. # @returns [Boolean] Whether the certificate and key were successfully loaded. def load(path = @path) certificate_path = File.join(path, "#{@hostname}.crt") key_path = File.join(path, "#{@hostname}.key") return false unless File.exist?(certificate_path) and File.exist?(key_path) certificate = OpenSSL::X509::Certificate.new(File.read(certificate_path)) key = OpenSSL::PKey::RSA.new(File.read(key_path)) # Certificates with old version need to be regenerated. return false if certificate.version < 2 @certificate = certificate @key = key return true end # Save the certificate and key to the given path. # # @parameter path [String] The path to save the certificate and key. def save(path = @path) lockfile_path = File.join(path, "#{@hostname}.lock") File.open(lockfile_path, File::RDWR|File::CREAT, 0644) do |lockfile| lockfile.flock(File::LOCK_EX) File.write( File.join(path, "#{@hostname}.crt"), self.certificate.to_pem ) File.write( File.join(path, "#{@hostname}.key"), self.key.to_pem ) end return true end # @returns A hash representation of the authority's certificate details. def to_h { hostname: @hostname, certificate_path: certificate_path, key_path: key_path, expires_at: certificate.not_after, } end def as_json(...) self.to_h end def to_json(...) self.as_json.to_json(...) end end end socketry-localhost-767eda0/lib/localhost/issuer.rb000066400000000000000000000104221513102103300223430ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "openssl" require "fileutils" require_relative "state" module Localhost # Represents a local Root Certificate Authority used to sign development certificates. class Issuer # The default number of bits for the private key. 4096 bits. BITS = 4096 # The default validity period for the certificate. 10 years in seconds. VALIDITY = 10 * 365 * 24 * 60 * 60 # Fetch (load or create) a certificate issuer with the given name. # See {#initialize} for the format of the arguments. def self.fetch(*arguments, **options) issuer = self.new(*arguments, **options) unless issuer.load issuer.save end return issuer end # The default certificate issuer name. NAME = "development" # Initialize the issuer with the given name. # # @parameter name [String] The common name to use for the certificate. # @parameter path [String] The path path for loading and saving the certificate. def initialize(name = nil, path: State.path) @name = name || NAME @path = path @subject = nil @key = nil @certificate = nil end # @returns [String] The path to the private key. def key_path File.join(@path, "#{@name}.key") end # @returns [String] The path to the public certificate. def certificate_path File.join(@path, "#{@name}.crt") end # @returns [OpenSSL::X509::Name] The subject name for the certificate. def subject @subject ||= OpenSSL::X509::Name.parse("/O=localhost.rb/CN=#{@name}") end # Set the subject name for the certificate. # # @parameter subject [OpenSSL::X509::Name] The subject name for the certificate. def subject= subject @subject = subject end # @returns [OpenSSL::PKey::RSA] The private key. def key @key ||= OpenSSL::PKey::RSA.new(BITS) end # The public certificate. # # @returns [OpenSSL::X509::Certificate] A self-signed certificate. def certificate @certificate ||= OpenSSL::X509::Certificate.new.tap do |certificate| certificate.subject = self.subject # We use the same issuer as the subject, which makes this certificate self-signed: certificate.issuer = self.subject certificate.public_key = self.key.public_key certificate.serial = Time.now.to_i certificate.version = 2 certificate.not_before = Time.now - 10 certificate.not_after = Time.now + VALIDITY extension_factory = ::OpenSSL::X509::ExtensionFactory.new extension_factory.subject_certificate = certificate extension_factory.issuer_certificate = certificate certificate.add_extension extension_factory.create_extension("basicConstraints", "CA:TRUE", true) certificate.add_extension extension_factory.create_extension("keyUsage", "keyCertSign, cRLSign", true) certificate.add_extension extension_factory.create_extension("subjectKeyIdentifier", "hash") certificate.add_extension extension_factory.create_extension("authorityKeyIdentifier", "keyid:always", false) certificate.sign self.key, OpenSSL::Digest::SHA256.new end end # Load the certificate and key from the given path. # # @parameter path [String] The path to load the certificate and key. # @returns [Boolean] True if the certificate and key were loaded successfully. def load(path = @root) certificate_path = self.certificate_path key_path = self.key_path return false unless File.exist?(certificate_path) and File.exist?(key_path) certificate = OpenSSL::X509::Certificate.new(File.read(certificate_path)) key = OpenSSL::PKey::RSA.new(File.read(key_path)) @certificate = certificate @key = key return true end # @returns [String] The path to the lockfile. def lockfile_path File.join(@path, "#{@name}.lock") end # Save the certificate and key to the given path. # # @parameter path [String] The path to save the certificate and key. def save(path = @root) lockfile_path = self.lockfile_path File.open(lockfile_path, File::RDWR|File::CREAT, 0644) do |lockfile| lockfile.flock(File::LOCK_EX) File.write( self.certificate_path, self.certificate.to_pem ) File.write( self.key_path, self.key.to_pem ) end return true end end end socketry-localhost-767eda0/lib/localhost/state.rb000066400000000000000000000020711513102103300221520ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "fileutils" module Localhost # Represents a single public/private key pair for a given hostname. module State # Where to store the key pair on the filesystem. This is a subdirectory # of $XDG_STATE_HOME, or ~/.local/state/ when that's not defined. # # Ensures that the directory to store the certificate exists. If the legacy # directory (~/.localhost/) exists, it is moved into the new XDG Basedir # compliant directory. # # @parameter env [Hash] The environment to use for configuration. def self.path(env = ENV) path = File.expand_path("localhost.rb", env.fetch("XDG_STATE_HOME", "~/.local/state")) unless File.directory?(path) FileUtils.mkdir_p(path, mode: 0700) end return path end # Delete the directory where the key pair is stored. # # @parameter env [Hash] The environment to use for configuration. def self.purge(env = ENV) path = self.path(env) return FileUtils.rm_rf(path) end end end socketry-localhost-767eda0/lib/localhost/system.rb000066400000000000000000000007541513102103300223640ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. module Localhost # @namespace module System # @return [Class] The best system class for the current platform. def self.current case RUBY_PLATFORM when /darwin/ require "localhost/system/darwin" Darwin when /linux/ require "localhost/system/linux" Linux else raise NotImplementedError, "Unsupported platform: #{RUBY_PLATFORM}" end end end end socketry-localhost-767eda0/lib/localhost/system/000077500000000000000000000000001513102103300220315ustar00rootroot00000000000000socketry-localhost-767eda0/lib/localhost/system/darwin.rb000066400000000000000000000014201513102103300236370ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. module Localhost module System # Darwin specific system operations. module Darwin # Install a certificate into the system trust store. # # @parameter certificate [String] The path to the certificate file. def self.install(certificate) login_keychain = File.expand_path("~/Library/Keychains/login.keychain-db") success = system( "security", "add-trusted-cert", "-d", "-r", "trustRoot", "-k", login_keychain, certificate ) if success $stderr.puts "Installed certificate to #{login_keychain}" return true else raise "Failed to install certificate: #{certificate}" end end end end end socketry-localhost-767eda0/lib/localhost/system/linux.rb000066400000000000000000000036421513102103300235220ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025-2026, by Samuel Williams. module Localhost module System # Linux specific system operations. module Linux # This appears to be the standard path for the system trust store on many Linux distributions. ANCHORS_PATH = "/etc/ca-certificates/trust-source/anchors/" UPDATE_CA_TRUST = "update-ca-trust" # OpenSUSE/SLES use this path for certificate anchors. OPENSUSE_ANCHORS_PATH = "/etc/pki/trust/anchors/" # This is an older method for systems that do not use `update-ca-trust`. LOCAL_CERTIFICATES_PATH = "/usr/local/share/ca-certificates/" UPDATE_CA_CERTIFICATES = "update-ca-certificates" # Install a certificate into the system trust store. # # @parameter certificate [String] The path to the certificate file. def self.install(certificate) filename = File.basename(certificate) command = nil if File.exist?(ANCHORS_PATH) # For systems using `update-ca-trust` (most Linux distributions). destination = File.join(ANCHORS_PATH, filename) command = UPDATE_CA_TRUST elsif File.exist?(OPENSUSE_ANCHORS_PATH) # For systems using `update-ca-certificates` (OpenSUSE/SLES). destination = File.join(OPENSUSE_ANCHORS_PATH, filename) command = UPDATE_CA_CERTIFICATES elsif File.exist?(LOCAL_CERTIFICATES_PATH) # For systems using `update-ca-certificates`. destination = File.join(LOCAL_CERTIFICATES_PATH, filename) command = UPDATE_CA_CERTIFICATES else raise "No known system trust store found. Please install the certificate manually." end success = system("sudo", "cp", certificate, destination) success &= system("sudo", command) if success $stderr.puts "Installed certificate to #{destination}" return true else raise "Failed to install certificate: #{certificate}" end end end end end socketry-localhost-767eda0/lib/localhost/version.rb000066400000000000000000000002261513102103300225170ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. module Localhost VERSION = "1.7.0" end socketry-localhost-767eda0/license.md000066400000000000000000000026661513102103300177250ustar00rootroot00000000000000# MIT License Copyright, 2018-2026, by Samuel Williams. Copyright, 2018, by Gabriel Sobrinho. Copyright, 2019, by Richard S. Leung. Copyright, 2020-2021, by Olle Jonsson. Copyright, 2021, by Akshay Birajdar. Copyright, 2021, by Ye Lin Aung. Copyright, 2022, by Juri Hahn. Copyright, 2023, by Antonio Terceiro. Copyright, 2023, by Yuuji Yaginuma. Copyright, 2024, by Colin Shea. Copyright, 2024, by Aurel Branzeanu. 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. socketry-localhost-767eda0/localhost.gemspec000066400000000000000000000016671513102103300213160ustar00rootroot00000000000000# frozen_string_literal: true require_relative "lib/localhost/version" Gem::Specification.new do |spec| spec.name = "localhost" spec.version = Localhost::VERSION spec.summary = "Manage a local certificate authority for self-signed localhost development servers." spec.authors = ["Samuel Williams", "Olle Jonsson", "Ye Lin Aung", "Akshay Birajdar", "Antonio Terceiro", "Aurel Branzeanu", "Colin Shea", "Gabriel Sobrinho", "Juri Hahn", "Richard S. Leung", "Yuuji Yaginuma"] spec.license = "MIT" spec.cert_chain = ["release.cert"] spec.signing_key = File.expand_path("~/.gem/release.pem") spec.homepage = "https://github.com/socketry/localhost" spec.metadata = { "documentation_uri" => "https://socketry.github.io/localhost/", "source_code_uri" => "https://github.com/socketry/localhost.git", } spec.files = Dir.glob(["{bake,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.2" end socketry-localhost-767eda0/readme.md000066400000000000000000000055261513102103300175360ustar00rootroot00000000000000# Localhost This gem provides a convenient API for generating per-user self-signed root certificates. [![Development Status](https://github.com/socketry/localhost/workflows/Test/badge.svg)](https://github.com/socketry/localhost/actions?workflow=Test) ## Motivation HTTP/2 requires SSL in web browsers. If you want to use HTTP/2 for development (and you should), you need to start using URLs like `https://localhost:8080`. In most cases, this requires adding a self-signed certificate to your certificate store (e.g. Keychain on macOS), and storing the private key for the web-server to use. I wanted to provide a server-agnostic way of doing this, primarily because I think it makes sense to minimise the amount of junky self-signed keys you add to your certificate store for `localhost`. ## Usage Please see the [project documentation](https://socketry.github.io/localhost/) for more details. - [Getting Started](https://socketry.github.io/localhost/guides/getting-started/index) - This guide explains how to use `localhost` for provisioning local TLS certificates for development. - [Example Server](https://socketry.github.io/localhost/guides/example-server/index) - This guide demonstrates how to use Localhost::Authority to implement a simple HTTPS client & server. ## Releases Please see the [project releases](https://socketry.github.io/localhost/releases/index) for all releases. ### v1.6.0 - Add support for `update-ca-trust` on Linux sytems. - Better command output. ### v1.4.0 - Add `localhost:purge` to delete all certificates. - Add `localhost:install` to install the issuer certificate in the local trust store. ## See Also - [Falcon](https://github.com/socketry/falcon) — Uses `Localhost::Authority` to provide HTTP/2 with minimal configuration. - [Puma](https://github.com/puma/puma) — Supports `Localhost::Authority` to provide self-signed HTTP for local development. ## Contributing We welcome contributions to this project. 1. Fork it. 2. Create your feature branch (`git checkout -b my-new-feature`). 3. Commit your changes (`git commit -am 'Add some feature'`). 4. Push to the branch (`git push origin my-new-feature`). 5. Create new Pull Request. ### Developer Certificate of Origin In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. ### Community Guidelines This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. socketry-localhost-767eda0/release.cert000066400000000000000000000033141513102103300202470ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= -----END CERTIFICATE----- socketry-localhost-767eda0/releases.md000066400000000000000000000004031513102103300200710ustar00rootroot00000000000000# Releases ## v1.6.0 - Add support for `update-ca-trust` on Linux sytems. - Better command output. ## v1.4.0 - Add `localhost:purge` to delete all certificates. - Add `localhost:install` to install the issuer certificate in the local trust store. socketry-localhost-767eda0/test/000077500000000000000000000000001513102103300167265ustar00rootroot00000000000000socketry-localhost-767eda0/test/localhost.rb000066400000000000000000000003661513102103300212500ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "localhost" describe Localhost do it "has a version number" do expect(Localhost::VERSION).to be =~ /\d+\.\d+\.\d+/ end end socketry-localhost-767eda0/test/localhost/000077500000000000000000000000001513102103300207165ustar00rootroot00000000000000socketry-localhost-767eda0/test/localhost/authority.rb000066400000000000000000000064341513102103300233020ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. # Copyright, 2021, by Ye Lin Aung. # Copyright, 2024, by Colin Shea. # Copyright, 2024, by Aurel Branzeanu. require "localhost/authority" require "sus/fixtures/async/reactor_context" require "io/endpoint/ssl_endpoint" require "fileutils" require "tempfile" describe Localhost::Authority do def around Dir.mktmpdir do |path| @root = path super ensure @root = nil end end let(:authority) {subject.new("localhost", path: @root)} with ".path" do it "returns the state path" do expect(subject.path).to be == Localhost::State.path end end it "have correct key and certificate path" do authority.save expect(File).to be(:exist?, authority.certificate_path) expect(File).to be(:exist?, authority.key_path) expect(File).to be(:exist?, File.expand_path("localhost.lock", @root)) expect(File).to be(:exist?, File.expand_path("localhost.crt", @root)) expect(File).to be(:exist?, File.expand_path("localhost.key", @root)) end with "#certificate" do it "is not valid for more than 1 year" do certificate = authority.certificate validity = certificate.not_after - certificate.not_before # https://support.apple.com/en-us/102028 expect(validity).to be <= 398 * 24 * 60 * 60 end end with "#dh_key" do it "is a DH key" do expect(authority.dh_key).to be_a OpenSSL::PKey::DH end end with "#subject" do it "can get subject" do expect(authority.subject.to_s).to be == "/O=localhost.rb/CN=localhost" end it "can set subject" do authority.subject = OpenSSL::X509::Name.parse("/CN=example.localhost") expect(authority.subject.to_s).to be == "/CN=example.localhost" end end with "#key" do it "is an RSA key" do expect(authority.key).to be_a OpenSSL::PKey::RSA end it "can set key" do # Avoid generating a key, it's slow... # key = OpenSSL::PKey::RSA.new(1024) key = authority.key authority.key = key expect(authority.key).to be_equal(key) end end with "#store" do it "can verify certificate" do expect(authority.store.verify(authority.certificate)).to be == true end end with "#server_context" do it "can generate appropriate ssl context" do expect(authority.server_context).to be_a OpenSSL::SSL::SSLContext end end with ".list" do before do authority.save end it "can list all authorities" do authorities = Localhost::Authority.list(@root).to_a expect(authorities.size).to be == 1 expect(authorities.first).to be_a Localhost::Authority expect(authorities.first).to have_attributes( hostname: be == "localhost", ) end end with ".fetch" do def before super authority.save end it "can fetch existing authority" do fetched_authority = Localhost::Authority.fetch("localhost", path: @root) expect(fetched_authority).to have_attributes( hostname: be == "localhost", ) end it "can create new authority" do fetched_authority = Localhost::Authority.fetch("example.com", path: @root) expect(fetched_authority).to have_attributes( hostname: be == "example.com", ) expect(File).to be(:exist?, fetched_authority.certificate_path) expect(File).to be(:exist?, fetched_authority.key_path) end end end socketry-localhost-767eda0/test/localhost/protocol.rb000066400000000000000000000036101513102103300231040ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. require "localhost/authority" require "sus/fixtures/async/http/server_context" require "io/endpoint/ssl_endpoint" require "fileutils" AValidProtocol = Sus::Shared("valid protocol") do |protocol, openssl_options, curl_options| it "can connect using #{protocol} using openssl" do uri = URI.parse(bound_url) status = system("openssl", "s_client", "-connect", "#{uri.host}:#{uri.port}", *openssl_options, in: IO::NULL) expect(status).to be == true end it "can connect using HTTP over #{protocol} using curl" do skip_if_ruby_platform("darwin") # curl on macOS does not support --tlsv1.3 status = system("curl", "--verbose", "--insecure", bound_url, *curl_options) expect(status).to be == true end end describe Localhost::Authority do # We test the actual authority: let(:authority) {subject.new} include Sus::Fixtures::Async::HTTP::ServerContext def url "https://localhost:0" end def timeout nil end def endpoint_options super.merge( ssl_context: authority.server_context ) end def make_client_endpoint(bound_endpoint) IO::Endpoint::SSLEndpoint.new(super, ssl_context: authority.client_context) end # Curl no longer supports this. # it_behaves_like "invalid protocol", "SSLv3", ["-ssl3"], ["--sslv3"] # Most modern browsers have removed support for these: # it_behaves_like "valid protocol", "TLSv1", ["-tls1"], ["--tlsv1"] # it_behaves_like "valid protocol", "TLSv1.1", ["-tls1_1"], ["--tlsv1.1"] it_behaves_like AValidProtocol, "default", [], [] it_behaves_like AValidProtocol, "TLSv1.2", ["-tls1_2"], ["--tlsv1.2"] it_behaves_like AValidProtocol, "TLSv1.3", ["-tls1_3"], ["--tlsv1.3"] it "can connect using HTTPS" do response = client.get("/") expect(response).to be(:success?) ensure response&.finish client.close end end socketry-localhost-767eda0/test/localhost/state.rb000066400000000000000000000007521513102103300223670ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "localhost/authority" require "sus/fixtures/async/reactor_context" require "io/endpoint/ssl_endpoint" require "fileutils" require "tempfile" describe Localhost::State do with ".path" do it "uses XDG_STATE_HOME" do env = {"XDG_STATE_HOME" => "/tmp/state"} expect(Localhost::State.path(env)).to be == File.expand_path("localhost.rb", "/tmp/state") end end end