rb-portless 0.1.0.dev.20260630.3812766

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a65f8dfa503c07be5175b77e26b01053bb3b558c6fb642ba98439787a52cece2
4
+ data.tar.gz: 7c3427fd573855b7ee0e091f5905e4fbcb4d04829d7f0e9b1ffed3aa524f5bab
5
+ SHA512:
6
+ metadata.gz: b29929df5fa80c3ec1baad36287c11bd738109b94e88b625ffe19601ee14ee530aa7db474ed00e75b1220217bb9a6e0cf13d5f9145fe0a8bcbbab908ba8b8891
7
+ data.tar.gz: '080a08f1368ce6b369e238a6661f6a473b7e2c5f06ebf859ae98f0ac80f91aca7c856518d9b14a227d8d30c5679e16a3a55c84f2f488a7548c47f5913f02873f'
data/AGENTS.md ADDED
@@ -0,0 +1,78 @@
1
+ # AGENTS.md — rb-portless
2
+
3
+ A native-Ruby port of Vercel's **portless** (`references/portless`, a Node/TS
4
+ monorepo). Goal: feature parity, HTTPS by default, framework-agnostic (Rails is
5
+ the first test target). This file is the map; read it before changing things.
6
+
7
+ ## What it does
8
+
9
+ `rb-portless run <cmd>` runs a dev server through a local reverse proxy reachable
10
+ at `https://<name>.localhost` — no port numbers. A random backend port is
11
+ injected as `PORT`; the proxy routes the named host to it.
12
+
13
+ ## Core mechanisms (ported from portless — keep these invariants)
14
+
15
+ - **No daemon IPC.** All coordination is files in `~/.rb-portless` (overridable
16
+ via `PORTLESS_STATE_DIR`): `routes.json` (host→port→pid) under a `mkdir` lock,
17
+ plus `proxy.pid`/`proxy.port`/CA files. The proxy watches `routes.json`.
18
+ - **Privileged port = re-exec under sudo.** For ports < 1024, re-run the CLI via
19
+ `sudo env PORTLESS_*=… ruby <cli> proxy start …`; the elevated process binds
20
+ the socket and chowns state files back to `SUDO_UID`. Fall back to `:1355` if
21
+ sudo is denied and no explicit port was given. Refuse in CI/no-TTY.
22
+ - **Health header.** Every proxied response carries `X-Portless-Rb`; a HEAD probe
23
+ is how we tell *our* proxy from any other process on a port.
24
+ - **Wildcard routing.** A route `name.localhost` also serves `*.name.localhost`
25
+ (exact match first, then `host.end_with?(".#{route}")`). Critical for
26
+ subdomain-per-tenant apps (e.g. `*.shirabe.org.localhost`).
27
+ - **Per-host SNI certs.** `*.localhost` wildcard certs are invalid at the
28
+ reserved-TLD boundary, so mint a leaf per hostname on the TLS SNI callback,
29
+ cached on disk + in memory.
30
+
31
+ ## Module map (`lib/portless/`)
32
+
33
+ | File | Role | portless source |
34
+ | --- | --- | --- |
35
+ | `constants.rb` | ports, thresholds, state-dir, header, markers | cli-utils.ts |
36
+ | `state.rb` | state-dir paths + chown-back-to-SUDO_UID | utils.ts |
37
+ | `config.rb` | portless.json + name/tld inference | config.ts, auto.ts |
38
+ | `free_port.rb` | random 4000–4999 finder (skip bad ports) | cli-utils.ts findFreePort |
39
+ | `route_store.rb` | routes.json + dir-lock + dead-pid reap | routes.ts |
40
+ | `health.rb` | X-Portless-Rb probe + discoverState | cli-utils.ts |
41
+ | `privilege.rb` | needs-sudo, sudo re-exec, 1355 fallback | cli.ts handleProxy |
42
+ | `hosts.rb` | /etc/hosts marked-block sync/clean | hosts.ts |
43
+ | `certs.rb` | OpenSSL CA + per-host SNI leaf certs | certs.ts |
44
+ | `trust.rb` | OS trust store install/remove | certs.ts trustCA |
45
+ | `proxy.rb` | async-http reverse proxy (h1/h2/tls/ws) | proxy.ts |
46
+ | `daemon.rb` | proxy start/stop, sudo bind, 1355 fallback | cli.ts handleProxy |
47
+ | `service.rb` | launchd / systemd boot service | service.ts |
48
+ | `frameworks.rb` | --port/--host injection (vite/astro/…) | cli-utils.ts |
49
+ | `runner.rb` | run cmd: port→env→spawn→register→supervise | cli.ts runApp |
50
+ | `rails.rb` | opt-in railtie (whitelist *.localhost in dev) | — |
51
+ | `cli.rb` | command dispatch | cli.ts main |
52
+
53
+ ## Status
54
+
55
+ - **Phase 0 ✅** scaffold + coordination layer (config, state, free_port,
56
+ route_store, health, privilege, hosts) + CLI dispatch.
57
+ - **Phase 1 ✅** HTTPS proxy on async-http (TLS+SNI, h1+wildcard routing,
58
+ X-Forwarded-*, loop guard), certs + macOS trust, runner, sudo bind + 1355
59
+ fallback. **Verified against shirabe** at `https://*.shirabe.org.localhost`.
60
+ - **Phase 2 ✅** HTTP/2, full command surface (doctor/clean/prune/alias/get/hosts),
61
+ boot service (launchd/systemd), daemon lifecycle.
62
+ - **Phase 3 ✅ (partial)** framework `--port`/`--host` injection, Linux CA trust,
63
+ optional `portless/rails` railtie.
64
+
65
+ ### Roadmap (not yet built)
66
+
67
+ - LAN mode (mDNS `.local` publishing) for phones/tablets.
68
+ - Public sharing via `tailscale serve|funnel` and `ngrok`.
69
+ - Monorepo multi-app (one proxy, many named apps).
70
+ - Windows CA trust + Task Scheduler service.
71
+ - WebSocket upgrade relay hardening + HTTP/2 to the backend.
72
+
73
+ ## Conventions
74
+
75
+ - Stdlib-first; the only runtime deps are `async` + `async-http` (for h1/h2/tls/ws).
76
+ - Minitest + fixtures-free tests in `test/`; isolate state via `PORTLESS_STATE_DIR`.
77
+ - Mirror portless's naming/structure so the two stay diffable against
78
+ `references/portless`.
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/), and the project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] — first release
8
+
9
+ The full portless workflow for Ruby, validated end-to-end against a real Rails
10
+ app (`rb-portless run bin/rails server` → `https://*.shirabe.org.localhost`).
11
+
12
+ ### Added
13
+
14
+ - **HTTP/2** with HTTP/1.1 fallback (server-side ALPN negotiation).
15
+ - **Commands:** `run`, `proxy start|stop`, `trust`, `service install|uninstall|status`,
16
+ `alias`, `get`, `list`, `hosts sync|clean`, `doctor`, `prune`, `clean`.
17
+ - **Boot service** — launchd (macOS) / systemd (Linux) for a no-prompt
18
+ privileged bind at boot.
19
+ - **CA trust** on macOS (login keychain) and Linux (distro anchors).
20
+ - **Framework `--port`/`--host` injection** for Vite, Astro, Angular, etc.
21
+ - Optional **Rails railtie** (`gem "rb-portless", require: "portless/rails"`)
22
+ that **auto-detects** when the app runs under `rb-portless` (via `PORTLESS_URL`)
23
+ and only then whitelists the matching `*.localhost` dev hosts — zero-config,
24
+ and a no-op when you run Rails normally.
25
+ - **Phase 1 — core.** `rb-portless run <cmd>` runs a dev server behind a local
26
+ HTTPS reverse proxy reachable at `https://<name>.localhost`:
27
+ - async-http TLS proxy with per-host SNI certs, Host + wildcard routing
28
+ (`*.name.localhost` → the app registered as `name.localhost`), `X-Forwarded-*`
29
+ headers, a loop guard, and a sibling `:80 → https` redirect.
30
+ - Native-OpenSSL local CA + on-demand per-host leaf certs; macOS keychain trust.
31
+ - `routes.json` registry (directory-lock + dead-pid reaping), `X-Portless-Rb`
32
+ health probe, and proxy auto-start.
33
+ - Privileged-port binding via one-time `sudo` re-exec, with a `:1355` fallback.
34
+ - Random backend port (4000–4999) injected as `PORT`/`HOST`.
35
+ - **Phase 0 — scaffold.** Gem skeleton, config (`portless.json` + name/tld
36
+ inference), state dir, CLI dispatch (`run`, `proxy`, `trust`, `list`, …).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Afonso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,177 @@
1
+ <h1 align="center">rb-portless</h1>
2
+
3
+ <p align="center">
4
+ <a href="https://rubygems.org/gems/rb-portless"><img src="https://img.shields.io/gem/v/rb-portless" alt="Gem Version"></a>
5
+ <a href="https://github.com/davafons/rb-portless/actions/workflows/ci.yml"><img src="https://github.com/davafons/rb-portless/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
6
+ <a href="https://github.com/davafons/rb-portless/blob/main/LICENSE"><img src="https://img.shields.io/github/license/davafons/rb-portless" alt="License"></a>
7
+ <a href="https://rubygems.org/gems/rb-portless"><img src="https://img.shields.io/gem/dt/rb-portless" alt="Downloads"></a>
8
+ </p>
9
+
10
+ <p align="center">
11
+ Stable, named <code>.localhost</code> URLs for local development —<br>
12
+ a native-Ruby port of <a href="https://github.com/vercel-labs/portless">Vercel's portless</a>.
13
+ </p>
14
+
15
+ ```diff
16
+ - bin/rails server # http://localhost:3000
17
+ + rb-portless run bin/rails server # https://myapp.localhost
18
+ ```
19
+
20
+ Run your dev server through a tiny local reverse proxy and reach it at
21
+ `https://<name>.localhost` instead of juggling ports. HTTPS by default (a local
22
+ CA + per-host certs), a random backend port so you never collide on 3000/3001,
23
+ and wildcard subdomains (`*.myapp.localhost`) so multi-tenant apps Just Work.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ gem install rb-portless
29
+ ```
30
+
31
+ - Ruby >= 3.2. macOS or Linux. (Windows: HTTP works; CA trust + boot service are
32
+ on the roadmap.)
33
+
34
+ ## Use
35
+
36
+ ```bash
37
+ rb-portless run bin/dev # -> https://<project>.localhost
38
+ rb-portless run bin/rails server # anything that respects $PORT
39
+ rb-portless run -- npm run dev # Vite/Astro/etc. get --port injected
40
+ ```
41
+
42
+ A random port (4000–4999) is injected as `PORT` (and `HOST=127.0.0.1`);
43
+ Rails/puma respect it natively. The proxy **auto-starts** on first run: it
44
+ generates a local CA, and binds 443 — a one-time `sudo` on macOS/Linux, exactly
45
+ like portless (falls back to `:1355` if you decline). Run `rb-portless trust`
46
+ once so your browser accepts the certificates.
47
+
48
+ ```bash
49
+ rb-portless trust # trust the local CA (HTTPS, no warnings)
50
+ rb-portless service install # bind 443 at boot — never prompt for sudo again
51
+ ```
52
+
53
+ ### Config (`portless.json`)
54
+
55
+ ```json
56
+ { "name": "shirabe", "tld": "shirabe.org.localhost" }
57
+ ```
58
+
59
+ | Key | Default | Meaning |
60
+ | --------- | ----------- | ------- |
61
+ | `name` | dir/git root | the subdomain label |
62
+ | `tld` | `localhost` | base host; a multi-label value like `shirabe.org.localhost` gives every `*.shirabe.org.localhost` subdomain, all routed to the one app |
63
+ | `tls` | `true` | HTTPS (`false` = plain HTTP on :80) |
64
+ | `appPort` | random | pin the backend port |
65
+
66
+ With a custom `tld`, every `*.shirabe.org.localhost` subdomain wildcard-routes to
67
+ the one app — ideal for subdomain-per-tenant apps.
68
+
69
+ ## Commands
70
+
71
+ | Command | Does |
72
+ | --- | --- |
73
+ | `run <cmd>` | run a dev server through the proxy |
74
+ | `proxy start \| stop` | manage the proxy daemon |
75
+ | `trust` | install the local CA into the OS trust store |
76
+ | `service install \| uninstall \| status` | bind the privileged port at boot (launchd/systemd) |
77
+ | `alias <name> <port>` | a static route (Docker, Postgres, …) |
78
+ | `get <name>` | print a name's URL (for `$(rb-portless get api)`) |
79
+ | `list` | show active routes |
80
+ | `hosts sync \| clean` | manage `/etc/hosts` (Safari / non-`.localhost` TLDs) |
81
+ | `doctor` | diagnose setup |
82
+ | `prune` | reap stale routes |
83
+ | `clean` | stop the proxy, untrust the CA, remove all state |
84
+
85
+ ## Rails
86
+
87
+ Rails is first-class: it respects `PORT` and trusts the loopback proxy, so
88
+ `X-Forwarded-Host/Proto/Port` flow through and `request.host`, subdomains, and
89
+ generated URLs all reflect `https://<name>.localhost`.
90
+
91
+ **Zero-config setup.** Add the gem to your dev group with the railtie required —
92
+ that's the only project change:
93
+
94
+ ```ruby
95
+ # Gemfile
96
+ group :development do
97
+ gem "rb-portless", require: "portless/rails"
98
+ end
99
+ ```
100
+
101
+ ```jsonc
102
+ // portless.json (optional — name defaults to the dir/git root)
103
+ { "name": "myapp", "tld": "myapp.localhost" }
104
+ ```
105
+
106
+ ```bash
107
+ rb-portless trust # one-time: trust the local CA
108
+ rb-portless run bin/dev # → https://myapp.localhost
109
+ ```
110
+
111
+ That's it. The railtie **auto-detects when you're running under `rb-portless`**
112
+ (via the `PORTLESS_URL` env the runner injects) and only then whitelists your
113
+ `*.localhost` hosts in development — so Action Dispatch host authorization doesn't
114
+ `403` your named subdomains. Run `bin/dev` normally and nothing is touched.
115
+
116
+ > **`bin/dev` note:** `rb-portless run bin/dev` wraps Foreman. Foreman passes the
117
+ > injected `PORT` to the **first** process in `Procfile.dev` — keep `web:` first
118
+ > (the Rails default) so the server binds the port the proxy registered.
119
+
120
+ Prefer not to add the gem? Skip the railtie and allow the host yourself:
121
+
122
+ ```ruby
123
+ # config/environments/development.rb
124
+ config.hosts << /.+\.localhost/
125
+ ```
126
+
127
+ ## Use cases
128
+
129
+ **Kill the port.** Stop memorizing `:3000` / `:3001`. One stable HTTPS URL per
130
+ app, the same every day:
131
+
132
+ ```bash
133
+ rb-portless run bin/dev # https://myapp.localhost
134
+ ```
135
+
136
+ **Subdomain-per-tenant apps** (the headline). A multi-label `tld` gives every
137
+ subdomain to one app, so multi-tenant / Classroom-style routing works locally
138
+ exactly like production:
139
+
140
+ ```jsonc
141
+ { "name": "myapp", "tld": "myapp.localhost" }
142
+ // kobe.myapp.localhost, osaka.myapp.localhost, admin.myapp.localhost → your app
143
+ ```
144
+
145
+ **Several services at once.** Give each its own name; route non-portless
146
+ processes (a database, a container) with a static `alias`:
147
+
148
+ ```bash
149
+ rb-portless run bin/dev # https://web.localhost
150
+ rb-portless run -- node api/server.js # https://api.localhost (in another tab)
151
+ rb-portless alias pg 5432 # https://pg.localhost (static)
152
+ ```
153
+
154
+ **HTTPS that matches prod.** Develop against real TLS + HTTP/2, so secure-cookie
155
+ and `X-Forwarded-Proto` behaviour is the same locally as in production.
156
+
157
+ ## How it works
158
+
159
+ No daemon protocol — coordination is a state dir (`~/.rb-portless`) with a
160
+ `routes.json` registry (host → backend port). The proxy resolves each request's
161
+ host to a backend and forwards it, minting a per-host TLS leaf cert on the SNI
162
+ callback (because `*.localhost` wildcard certs aren't valid at the reserved-TLD
163
+ boundary). For ports < 1024 it re-execs under `sudo` so the elevated process can
164
+ bind the socket, then hands ownership of state files back to you. See
165
+ [`AGENTS.md`](AGENTS.md) for the full architecture.
166
+
167
+ ## Contributing
168
+
169
+ ```bash
170
+ bundle install
171
+ bundle exec rake test
172
+ bundle exec rubocop
173
+ ```
174
+
175
+ ## License
176
+
177
+ MIT.
data/exe/rb-portless ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ begin
5
+ require "rb-portless"
6
+ rescue LoadError
7
+ require_relative "../lib/rb-portless"
8
+ end
9
+
10
+ Portless::CLI.start(ARGV)
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "fileutils"
5
+
6
+ module Portless
7
+ # Local CA + per-host leaf certs, all in native Ruby OpenSSL (portless shells
8
+ # out to the openssl binary; we don't have to). Because *.localhost wildcard
9
+ # certs aren't honoured at the reserved-TLD boundary, every SNI hostname gets
10
+ # its own leaf, minted on demand and cached on disk + in memory.
11
+ class Certs
12
+ CA_SUBJECT = "/CN=rb-portless Local CA"
13
+ CA_DAYS = 3650
14
+ LEAF_DAYS = 365
15
+ CURVE = "prime256v1"
16
+
17
+ def initialize
18
+ @leaves = {} # hostname => [cert, key]
19
+ end
20
+
21
+ # The CA certificate (PEM-loaded), generating + persisting one on first use.
22
+ def ca_certificate
23
+ @ca_certificate ||= begin
24
+ ensure_ca!
25
+ OpenSSL::X509::Certificate.new(File.read(State.ca_cert))
26
+ end
27
+ end
28
+
29
+ def ca_key
30
+ @ca_key ||= begin
31
+ ensure_ca!
32
+ OpenSSL::PKey.read(File.read(State.ca_key))
33
+ end
34
+ end
35
+
36
+ # SHA-256 fingerprint — used by the trust marker + OS trust check.
37
+ def ca_fingerprint
38
+ OpenSSL::Digest::SHA256.hexdigest(ca_certificate.to_der)
39
+ end
40
+
41
+ # [cert, key] for an SNI hostname. Cached in memory; persisted under
42
+ # host-certs/ so a proxy restart doesn't re-mint everything.
43
+ def leaf_for(hostname)
44
+ hostname = hostname.to_s.downcase
45
+ @leaves[hostname] ||= load_leaf(hostname) || generate_leaf(hostname)
46
+ end
47
+
48
+ def ensure_ca!
49
+ return if File.exist?(State.ca_cert) && File.exist?(State.ca_key)
50
+
51
+ State.ensure_dir!
52
+ key = OpenSSL::PKey::EC.generate(CURVE)
53
+ cert = OpenSSL::X509::Certificate.new
54
+ name = OpenSSL::X509::Name.parse(CA_SUBJECT)
55
+ cert.version = 2
56
+ cert.serial = random_serial
57
+ cert.subject = name
58
+ cert.issuer = name
59
+ cert.public_key = ec_public(key)
60
+ cert.not_before = Time.now - 60
61
+ cert.not_after = Time.now + CA_DAYS * 86_400
62
+
63
+ ef = OpenSSL::X509::ExtensionFactory.new
64
+ ef.subject_certificate = cert
65
+ ef.issuer_certificate = cert
66
+ cert.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true))
67
+ cert.add_extension(ef.create_extension("keyUsage", "keyCertSign,cRLSign", true))
68
+ cert.add_extension(ef.create_extension("subjectKeyIdentifier", "hash"))
69
+ cert.sign(key, OpenSSL::Digest.new("SHA256"))
70
+
71
+ write_secret(State.ca_key, key.to_pem)
72
+ File.write(State.ca_cert, cert.to_pem)
73
+ State.fix_ownership
74
+ end
75
+
76
+ private
77
+
78
+ def generate_leaf(hostname)
79
+ key = OpenSSL::PKey::EC.generate(CURVE)
80
+ cert = OpenSSL::X509::Certificate.new
81
+ cert.version = 2
82
+ cert.serial = random_serial
83
+ cert.subject = OpenSSL::X509::Name.new([ [ "CN", hostname ] ])
84
+ cert.issuer = ca_certificate.subject
85
+ cert.public_key = ec_public(key)
86
+ cert.not_before = Time.now - 60
87
+ cert.not_after = Time.now + LEAF_DAYS * 86_400
88
+
89
+ ef = OpenSSL::X509::ExtensionFactory.new
90
+ ef.subject_certificate = cert
91
+ ef.issuer_certificate = ca_certificate
92
+ cert.add_extension(ef.create_extension("basicConstraints", "CA:FALSE", true))
93
+ cert.add_extension(ef.create_extension("keyUsage", "digitalSignature,keyEncipherment", true))
94
+ cert.add_extension(ef.create_extension("extendedKeyUsage", "serverAuth"))
95
+ cert.add_extension(ef.create_extension("subjectAltName", san_for(hostname)))
96
+ cert.add_extension(ef.create_extension("subjectKeyIdentifier", "hash"))
97
+ cert.sign(ca_key, OpenSSL::Digest.new("SHA256"))
98
+
99
+ persist_leaf(hostname, cert, key)
100
+ [ cert, key ]
101
+ end
102
+
103
+ # The exact host plus a same-level wildcard (so api.x and x both verify when
104
+ # one is reached via the other).
105
+ def san_for(hostname)
106
+ sans = [ "DNS:#{hostname}" ]
107
+ parts = hostname.split(".")
108
+ sans << "DNS:*.#{parts[1..].join('.')}" if parts.length > 2
109
+ sans.join(",")
110
+ end
111
+
112
+ def load_leaf(hostname)
113
+ cert_path, key_path = leaf_paths(hostname)
114
+ return unless File.exist?(cert_path) && File.exist?(key_path)
115
+
116
+ cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
117
+ return if cert.not_after < Time.now + 7 * 86_400 # expiring soon → re-mint
118
+
119
+ [ cert, OpenSSL::PKey.read(File.read(key_path)) ]
120
+ rescue StandardError
121
+ nil
122
+ end
123
+
124
+ def persist_leaf(hostname, cert, key)
125
+ cert_path, key_path = leaf_paths(hostname)
126
+ FileUtils.mkdir_p(State.host_certs_dir)
127
+ File.write(cert_path, cert.to_pem)
128
+ write_secret(key_path, key.to_pem)
129
+ State.fix_ownership(State.host_certs_dir)
130
+ end
131
+
132
+ def leaf_paths(hostname)
133
+ safe = hostname.gsub(/[^a-z0-9.-]/, "_")
134
+ [ File.join(State.host_certs_dir, "#{safe}.pem"), File.join(State.host_certs_dir, "#{safe}-key.pem") ]
135
+ end
136
+
137
+ # EC public-only key for embedding in a cert. The `public_key=` setter is
138
+ # gone in OpenSSL 3, so round-trip through the public PEM.
139
+ def ec_public(key)
140
+ OpenSSL::PKey.read(key.public_to_pem)
141
+ end
142
+
143
+ def random_serial = OpenSSL::BN.rand(159)
144
+
145
+ def write_secret(path, pem)
146
+ File.write(path, pem)
147
+ File.chmod(0o600, path)
148
+ end
149
+ end
150
+ end