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 +7 -0
- data/AGENTS.md +78 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/rb-portless +10 -0
- data/lib/portless/certs.rb +150 -0
- data/lib/portless/cli.rb +222 -0
- data/lib/portless/config.rb +64 -0
- data/lib/portless/constants.rb +41 -0
- data/lib/portless/daemon.rb +99 -0
- data/lib/portless/frameworks.rb +43 -0
- data/lib/portless/free_port.rb +39 -0
- data/lib/portless/health.rb +67 -0
- data/lib/portless/hosts.rb +52 -0
- data/lib/portless/privilege.rb +44 -0
- data/lib/portless/proxy.rb +161 -0
- data/lib/portless/rails.rb +26 -0
- data/lib/portless/rails_hosts.rb +29 -0
- data/lib/portless/route_store.rb +127 -0
- data/lib/portless/runner.rb +81 -0
- data/lib/portless/service.rb +129 -0
- data/lib/portless/state.rb +52 -0
- data/lib/portless/trust.rb +104 -0
- data/lib/portless/version.rb +5 -0
- data/lib/rb-portless.rb +28 -0
- metadata +106 -0
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,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
|