ask-local 0.1.0

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: 6b2c1d943d3bbfb315651a8bd50deb3519f325822e894f1c4040d30b7523cb46
4
+ data.tar.gz: 9ad93192b70b62cae5292b5a1784bd9086e4103dba7d4f2a07aa83b7327bf795
5
+ SHA512:
6
+ metadata.gz: 74920fa209896d3a66f67245c6757f45f9c23793147dabfdf7e1f088f0c6af9fcb41442f44dcd1657816617aa88eaa318a5c57fde6035dc767704812bef7f0ff
7
+ data.tar.gz: c65585ddefac14be0b4898f3e5af9b96f370826bb11b43bc2f2ace9ce3dd350b1f8ba6b0a6b6bd45f2f43eecf1a34f4831bb1fff439087b01ce6ef1da379d169
data/CHANGELOG.md ADDED
@@ -0,0 +1,119 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] — Unreleased
4
+
5
+ ### Changed
6
+
7
+ - `cli.rb` (757 lines) split into command objects behind a shared
8
+ Context: `cli/boot.rb` (run/boot/supervision), `cli/routes.rb`
9
+ (get/alias/list/prune/stop/restart/log/status/open),
10
+ `cli/system.rb` (proxy/service/hosts/trust/clean/doctor/kamal).
11
+ - Default `rake test` runs the fast unit suite (~6s); `rake test:e2e`
12
+ runs daemon/TLS/live-boot tests; `rake test:all` runs everything.
13
+
14
+ ### Added
15
+
16
+ - Daemon-owned supervision (puma-dev model): managed apps idle-stop
17
+ after 15 minutes (`ASK_LOCAL_IDLE_TIMEOUT`), stop when tmp/restart.txt
18
+ changes, and boot transparently on the next request.
19
+ - `ask-local stop` (exit 0 stopped / 2 no route / 3 backend already
20
+ gone), `restart`, `log [-f] [n]`, `status` (effective naming context),
21
+ `open [name]` (browser). `list` shows backend liveness per route.
22
+ - Root-owned `service install` (launchd/systemd) binding 80/443 at boot
23
+ with the invoking user's state dir; sudo re-exec when needed.
24
+ - All root write paths chown state back to the invoking user; `doctor`
25
+ reports an unwritable state dir plainly.
26
+ - Bounded proxy concurrency (`ASK_LOCAL_MAX_CONNECTIONS`, 503 past the
27
+ cap), mtime-TTL route cache, IPv4+IPv6 loopback listeners, dual-stack
28
+ `ours?` health check.
29
+ - `get` inherits variant/TLD context from the current directory
30
+ (`get backend` in a fix-ui worktree -> fix-ui.backend.localhost);
31
+ `--service/--variant/--tld` overrides.
32
+ - `alias` accepts full hostnames on any TLD and honors ASK_LOCAL_TLD.
33
+ - `kamal` snippet resolves the app from the directory; `--app/--domain`
34
+ flags and ASK_LOCAL_KAMAL_DOMAIN.
35
+ - `clean` untrusts the CA from the OS trust store.
36
+ - `--proc <name>` picks a specific Procfile process.
37
+ - Ships the `ask-local` agent skill (ask/skills/ask-local/SKILL.md).
38
+ - `test:e2e` / `test:all` rake tasks; CI matrix (3.2/3.3/3.4/4.0),
39
+ macOS e2e leg, fixture-sweep job.
40
+ - Ownership module: root write paths chown state back to the invoking
41
+ user; `doctor` reports an unwritable state dir with the fix.
42
+ - Proxy hardening: bounded concurrency (503 past the cap), mtime-TTL
43
+ route cache, IPv4+IPv6 loopback listeners, dual-stack health check,
44
+ oversize-hostname refusal.
45
+ - `status` (effective naming context), `open [name]`, `log -f`;
46
+ `stop` exit codes 0/2/3; `list` shows backend liveness.
47
+ - Sinatra-modular + foreman-`$PORT` fixtures; no-double-injection guard.
48
+ - SKILL.md "when NOT to use" section (CI, prod, Docker networks).
49
+ - Framework coverage: hanami2 slice layout, jekyll livereload second
50
+ port (`--livereload-port` pinned next to the main port when
51
+ `livereload: true`), roda-plugins managed boot; no-double-injection
52
+ guard for explicit `$PORT`.
53
+ - Chunked request uploads pinned by test (streamed intact,
54
+ close-delimited).
55
+ - README non-goals section (HTTP/2, tunnels, production) with rationale.
56
+ - DNS-rebinding boundary: foreign Hosts get a bare 404; only our own
57
+ TLDs see the route-listing 404. Proxy takes `--tld` (persisted) so the
58
+ boundary follows custom domains.
59
+ - Log rotation (5MB, one generation) on proxy + backend logs; `doctor`
60
+ disk-usage check.
61
+ - `--json` on list/status/doctor with stable keys; `status` sources made
62
+ explicit (no more `(from -)`).
63
+ - Route cache keyed on file mtime (no TTL race for boot-then-curl);
64
+ foreground supervision polls at 2Hz with the detached-children
65
+ rationale documented.
66
+
67
+ ### Fixed
68
+
69
+ - Daemon spawn and service install resolved the ask-local binary one
70
+ directory too high — `proxy start` failed outright; failures now
71
+ include the proxy log tail.
72
+ - Keep-alive connections now rewrite headers (X-Forwarded-Proto) on
73
+ every request, not just the first — request 2+ previously reached the
74
+ backend unrewritten, breaking ssl detection and OmniAuth callbacks.
75
+ - Ctrl+C / TERM stops the backend process (no orphans); the CLI exits
76
+ when the backend dies, cleaning up routes.
77
+ - SNI certificate minting is arity-agnostic across ruby-openssl versions
78
+ (callback args differ; a raise surfaced as an unrecognized-name alert).
79
+ - Proxy honors HTTP/1.1 framing per request: Content-Length bodies are
80
+ forwarded exactly; chunked bodies and responses close-delimit.
81
+ - Bidirectional streaming terminates promptly on `Connection: close`
82
+ (each pump direction closes its peer on EOF).
83
+ - `alias --remove` now appends the default TLD.
84
+ - `base64` declared as a runtime dependency (left the default gems in
85
+ Ruby 3.4).
86
+ - Missing `require "optparse"` lost in the CLI split.
87
+ - Install generator `source_root` pointed at a doubled path; generator
88
+ file checks now resolve against `destination_root`.
89
+ - Port-flag injection no longer double-sets an explicit `$PORT`.
90
+
91
+ ## [0.1.0]
92
+
93
+ ### Added
94
+
95
+ - Initial release: explicit-run reverse proxy giving every Ruby app a
96
+ stable `https://<app>.localhost` URL.
97
+ - Zero-flag name inference (Rails module, gemspec, package.json, git
98
+ root, directory) with `ask-local.json` overrides.
99
+ - `{variant}.{service}.{app}.{tld}` hostname composition; linked-worktree
100
+ branch prefixes; custom `--tld` including owned domains.
101
+ - Managed Rack boot on unix sockets (rackup/TCP fallback); run mode with
102
+ `PORT`/`ASK_LOCAL_URL` injection; `Procfile.dev` and static-site
103
+ framework detection.
104
+ - Local CA + per-host SNI certs (in-memory LRU), `trust`, `hosts sync`,
105
+ `doctor`, `prune`, `alias`, `get`, `kamal` snippet.
106
+ - Explicit `--port/--host` injection for port-ignoring CLIs (Jekyll,
107
+ Middleman, Bridgetown), skipped when the user already set a port.
108
+
109
+ ### Changed
110
+
111
+ - Rails module inference kebab-cases CamelCase and digit runs:
112
+ `Rails8Min` → `rails-8-min` (was `rails8min`).
113
+ - Monorepo `ask-local.json` with an `apps:` map is discovered by walking
114
+ up from the package directory.
115
+ - `service: web` produces the bare `app.tld` (all other services prefix).
116
+ - Puma 8 command shape (positional `config.ru`; `--rackup` was removed
117
+ upstream) and spawned backends run unbundled so system/bundle puma is
118
+ reachable regardless of the invoking bundle.
119
+ - Boot failures include the backend log tail in the error message.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
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,200 @@
1
+ # ask-local
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/ask-local.svg)](https://badge.fury.io/rb/ask-local)
4
+
5
+ Stable named `.localhost` URLs for Ruby development. Gives every Ruby app
6
+ a stable `https://<app>.localhost` URL instead of a memorized port.
7
+ Zero runtime dependencies — Ruby stdlib only (`openssl`, `socket`).
8
+
9
+ ```bash
10
+ gem install ask-local
11
+ cd ~/code/myapp && ask-local
12
+ # -> https://myapp.localhost
13
+ ```
14
+
15
+ ## How it works
16
+
17
+ 1. `ask-local` infers your app name (Rails module, gemspec,
18
+ `package.json`, git root, or directory) and boots it — managed Rack
19
+ apps on a unix socket (zero TCP ports), anything else via `PORT`.
20
+ 2. Registers `hostname -> backend -> pid` in `~/.ask-local/routes.json`.
21
+ 3. The reverse proxy (HTTPS on 443, per-host certs from a local CA)
22
+ routes by `Host` header to your app.
23
+
24
+ `.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
25
+ no DNS server, no `/etc/resolver`. Safari may need `ask-local hosts sync`.
26
+
27
+ ## Hostname shape
28
+
29
+ ```
30
+ {variant}.{service}.{app}.{tld}
31
+ ```
32
+
33
+ | Axis | Example | Source |
34
+ |---|---|---|
35
+ | app | `myapp` | inferred, `--name`, `ask-local.json`, `ASK_LOCAL_NAME` |
36
+ | service | `api.myapp` | `--service`, `ASK_LOCAL_SERVICE` (`web` stays bare) |
37
+ | variant | `fix-ui.myapp` | `--variant`, `ASK_LOCAL_VARIANT`, linked worktree branch |
38
+ | tld | `myapp.preview.example.com` | `--tld` (default `localhost`) |
39
+
40
+ Linked git worktrees get a branch prefix automatically
41
+ (`fix-ui.myapp.localhost`); the main checkout keeps the bare name.
42
+ Pass `--branch` (or `ASK_LOCAL_BRANCH=1`) to prefix by current branch
43
+ outside worktrees. `main`/`master`/detached HEAD never prefix.
44
+
45
+ ```bash
46
+ ask-local # -> https://myapp.localhost
47
+ ask-local --service api # -> https://api.myapp.localhost
48
+ ask-local --variant demo # -> https://demo.myapp.localhost
49
+ ask-local --tld preview.example.com # your own domain (OAuth parity)
50
+ ```
51
+
52
+ ## Commands
53
+
54
+ ```bash
55
+ ask-local # infer name, boot app
56
+ ask-local run -- <cmd> # run explicit command through proxy
57
+ ask-local <name> <cmd> # explicit app name
58
+ ask-local get <name> # print URL for cross-service wiring
59
+ ask-local alias <name> <port> # static route (e.g. Docker)
60
+ ask-local list [--json] # show active routes (+ backend liveness)
61
+ ask-local status [--json] # show effective naming context here
62
+ ask-local doctor [--json] # machine-readable health checks
63
+ ask-local open [name] # open the app URL in a browser
64
+ ask-local doctor # read-only health check (state, proxy, routes, DNS, CA)
65
+ ask-local trust # add local CA to system trust store
66
+ ask-local clean # remove state and hosts entries
67
+ ask-local prune # remove stale routes
68
+ ask-local stop # stop this app's backend + routes
69
+ ask-local restart # touch tmp/restart.txt (managed apps reboot)
70
+ ask-local log [-f] [n] # tail (or follow) this app's backend log
71
+ ask-local proxy start|stop # control the proxy
72
+ ask-local service install|status|uninstall # root-owned OS startup service
73
+ ask-local hosts sync|clean # manage /etc/hosts entries
74
+ ask-local kamal <variant> # preview-deploy snippet for Kamal
75
+ ```
76
+
77
+ Child processes receive `ASK_LOCAL_URL` (the stable URL — use it for
78
+ OAuth callbacks, mailer hosts, webhook URLs), `PORT`, and `HOST`.
79
+
80
+ ## Frameworks
81
+
82
+ Rails and bare Rack (`config.ru`) boot managed on a unix socket (Puma
83
+ when available; `rackup` on TCP otherwise). `Procfile.dev`/`bin/dev`,
84
+ Jekyll, Bridgetown, and Middleman run in run mode with `PORT` injected
85
+ (port-ignoring CLIs get explicit `--port/--host` flags). Anything else:
86
+ `ask-local run -- <cmd>`.
87
+
88
+ ```bash
89
+ ask-local --proc web # boot a specific Procfile process
90
+ ask-local --proc worker # (first line is the default)
91
+ ```
92
+
93
+ Procfile lines that are compound (`&&`, `||`, `|`, `;`) are refused with
94
+ guidance rather than silently mis-injected.
95
+
96
+ For Rails integration (hosts, Action Cable origins, Procfile rewrite,
97
+ generators), see `ask-local-rails`.
98
+
99
+ ## WebSockets
100
+
101
+ Action Cable and any Rack hijack-based WebSocket server work through the
102
+ proxy: HTTP/1.1 `Upgrade` requests are byte-forwarded to the backend
103
+ after header rewriting, and the tunnel stays raw for the life of the
104
+ connection (verified end-to-end: RFC 6455 handshake + frame echo).
105
+
106
+ ## Machine-readable output
107
+
108
+ `list`, `status`, and `doctor` accept `--json` with stable keys for
109
+ agents and scripts (`DevUrl` in ask-ruby-harness consumes the same data
110
+ in-process). Hostnames that fall outside the configured TLDs get a bare
111
+ 404 naming nothing — route names never leak to foreign hosts
112
+ (DNS-rebinding boundary).
113
+
114
+ ## Log rotation
115
+
116
+ `proxy.log` and per-app backend logs rotate at 5MB
117
+ (`ASK_LOCAL_LOG_MAX_BYTES`), keeping one generation. `doctor` warns
118
+ when the state dir passes 100MB.
119
+
120
+ ## Supervision
121
+
122
+ Managed apps are supervised by the proxy daemon, not the CLI:
123
+
124
+ - idle backends stop after 15 minutes (`ASK_LOCAL_IDLE_TIMEOUT`
125
+ seconds; `0` disables) and boot transparently on the next request
126
+ - touching `tmp/restart.txt` stops the backend; next request reboots it
127
+ - crashed backends are detected and rebooted on the next request
128
+ - daemon shutdown stops every supervised backend (no orphans)
129
+
130
+ Run-mode (TCP) routes and static aliases are never supervised.
131
+
132
+ ## Ask ecosystem integration
133
+
134
+ | Gem | How ask-local helps |
135
+ |---|---|
136
+ | `ask-rails` | `ask_local:install` equivalent wiring: `config.hosts` patterns, `allowed_request_origins` for Cable, mailer/OmniAuth hosts from `ASK_LOCAL_URL` |
137
+ | `ask-rails-harness` | Its 9 Rails tools (routes, models, DB, logs) run against the app the proxy serves; `DevUrl` gives the agent the stable URL instead of a guessed port |
138
+ | `ask-app-server` | The JSON-RPC/stdio session host sits behind `https://api.<app>.localhost`; editor/IDE clients use `ask-local get` output |
139
+ | `ask-mcp` | MCP servers get named URLs per service (`mcp.<app>.localhost`), no port coordination across servers |
140
+ | `ask-skills` | Ships the `ask-local` skill (auto-discovered): boot via `ask-local`, wire via `get`, callbacks from `ASK_LOCAL_URL` |
141
+ | `ask-ruby-harness` | `DevUrl` tool: structured `list`/`get` for agents, audit-logged like every other tool |
142
+
143
+ What we do differently from Kamal for local dev: Kamal + kamal-proxy
144
+ own production (Let's Encrypt, zero-downtime deploys, multi-host).
145
+ ask-local never serves prod — but the variant slug is shared, so
146
+ `fix-ui.myapp.localhost` locally and `myapp-fix-ui.preview.example.com`
147
+ in staging (via `ask-local kamal fix-ui`) are the same branch everywhere.
148
+
149
+ ## Prior art
150
+
151
+ Same problem, three generations — ask-local borrows from all of them:
152
+
153
+ - **Pow** (2011–2017, macOS-only Rack): the ergonomics — zero-config
154
+ names, `tmp/restart.txt`, `.powrc` env loading. Left behind: Nack
155
+ workers, firewall forwarding, HTTP-only, unmaintained.
156
+ - **puma-dev** (Go, macOS/Linux): the engine semantics — Puma on unix
157
+ sockets, lazy boot, idle kill, restart.txt watching, in-memory dynamic
158
+ TLS, per-route status. Kept as behavior, reimplemented in Ruby.
159
+ - **portless** (Node 24, any stack): the agent interface — explicit run
160
+ ownership, `PORTLESS_URL`-style env contract, `get/doctor/prune`,
161
+ worktree prefixes, custom-TLD OAuth parity, SKILL.md pattern.
162
+
163
+ Build vs borrow decision: ask-local is pure Ruby (stdlib + base64),
164
+ not a wrapper around puma-dev's Go core. Rationale: zero-toolchain
165
+ distribution (`gem install`, no Go/Node), the ask-core zero-dependency
166
+ philosophy, and full control over the agent surface (route store,
167
+ supervision, skills). puma-dev's semantics were ported, not its binary.
168
+
169
+ ## Non-goals (deliberate)
170
+
171
+ - **HTTP/2.** Ruby dev servers serve a handful of requests, not Vite's
172
+ hundreds of unbundled files — the multiplexing win doesn't apply, and
173
+ ALPN/HPACK/stream state would triple the proxy's auditable surface.
174
+ Revisit only on benchmarked HMR latency. (Consequence: no HTTP/2
175
+ extended-CONNECT bridging; browsers never negotiate h2 here, so plain
176
+ Upgrade tunneling covers Action Cable fully.)
177
+ - **LAN/mDNS and Tailscale/ngrok tunnels.** mDNS behaves differently on
178
+ every network; tunnels need third-party CLIs, auth state, and accounts.
179
+ The 95% "show this branch to someone" case is covered by the `kamal`
180
+ preview-deploy snippet on real infrastructure instead of a laptop
181
+ tunnel. Kamal owns remote access; ask-local owns local naming.
182
+ - **Production serving.** The proxy binds loopback only, the CA is
183
+ self-signed, and there is no request buffering, rate limiting, or
184
+ access control. Anything real goes through Kamal + kamal-proxy.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ bundle install
190
+ bundle exec rake test
191
+ ```
192
+
193
+ The `ask-local-apps` fixture fleet (sibling checkout) exercises
194
+ detection, inference, and boot across Rails variants, Roda, Sinatra,
195
+ bare Rack, Jekyll, compound Procfiles, and a monorepo. CI runs the
196
+ fixture sweep automatically.
197
+
198
+ ## License
199
+
200
+ MIT
data/bin/ask-local ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ask-local"
5
+
6
+ exit Ask::Local::CLI.run(ARGV)
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "openssl"
5
+
6
+ module Ask
7
+ module Local
8
+ # Local CA + per-hostname certificates, all in-process via OpenSSL.
9
+ #
10
+ # *.localhost sits at a public-suffix boundary so a wildcard cert is
11
+ # not honored; every hostname gets an exact-SAN cert minted on demand
12
+ # through the SNI callback and held in an in-memory LRU
13
+ # (puma-dev ssl.go does the same; portless caches on disk instead).
14
+ module Certs
15
+ CA_COMMON_NAME = "Ask Local CA"
16
+ CA_VALIDITY_DAYS = 3650
17
+ HOST_VALIDITY_DAYS = 825
18
+ CACHE_SIZE = 1024
19
+
20
+ module_function
21
+
22
+ def state_dir
23
+ ENV["ASK_LOCAL_STATE_DIR"] || File.join(home, ".ask-local")
24
+ end
25
+
26
+ def home
27
+ ENV["HOME"] || Dir.home
28
+ rescue ArgumentError
29
+ Dir.pwd
30
+ end
31
+
32
+ def ca_paths(dir = state_dir)
33
+ { cert: File.join(dir, "ca.pem"), key: File.join(dir, "ca-key.pem") }
34
+ end
35
+
36
+ def ensure_ca(dir = state_dir)
37
+ FileUtils.mkdir_p(dir, mode: 0o755)
38
+ Ownership.fix(dir)
39
+ paths = ca_paths(dir)
40
+ return paths if valid_pair?(paths[:cert], paths[:key])
41
+
42
+ key = OpenSSL::PKey::EC.generate("prime256v1")
43
+ cert = OpenSSL::X509::Certificate.new
44
+ cert.version = 2
45
+ cert.serial = OpenSSL::BN.rand(128, 0)
46
+ cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=#{CA_COMMON_NAME}")
47
+ cert.not_before = Time.now - 3600
48
+ cert.not_after = Time.now + (CA_VALIDITY_DAYS * 86_400)
49
+ cert.public_key = key
50
+ ef = OpenSSL::X509::ExtensionFactory.new
51
+ ef.subject_certificate = cert
52
+ ef.issuer_certificate = cert
53
+ cert.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true))
54
+ cert.add_extension(ef.create_extension("keyUsage", "keyCertSign,cRLSign", true))
55
+ cert.sign(key, "SHA256")
56
+
57
+ File.write(paths[:key], key.to_pem, mode: "w", perm: 0o600)
58
+ File.write(paths[:cert], cert.to_pem, mode: "w", perm: 0o644)
59
+ Ownership.fix(paths[:key], paths[:cert])
60
+ paths
61
+ rescue OpenSSL::OpenSSLError => e
62
+ raise CertError, "Could not generate local CA: #{e.message}"
63
+ end
64
+
65
+ def load_ca(dir = state_dir)
66
+ paths = ensure_ca(dir)
67
+ [OpenSSL::X509::Certificate.new(File.read(paths[:cert])),
68
+ OpenSSL::PKey.read(File.read(paths[:key]))]
69
+ end
70
+
71
+ # Mint a leaf cert for one hostname, signed by the CA.
72
+ def mint_host(hostname, ca_cert, ca_key)
73
+ key = OpenSSL::PKey::EC.generate("prime256v1")
74
+ cert = OpenSSL::X509::Certificate.new
75
+ cert.version = 2
76
+ cert.serial = OpenSSL::BN.rand(128, 0)
77
+ cert.subject = OpenSSL::X509::Name.parse("/CN=#{hostname[0, 64]}")
78
+ cert.issuer = ca_cert.subject
79
+ cert.not_before = Time.now - 3600
80
+ cert.not_after = Time.now + (HOST_VALIDITY_DAYS * 86_400)
81
+ cert.public_key = key
82
+ ef = OpenSSL::X509::ExtensionFactory.new
83
+ ef.subject_certificate = cert
84
+ ef.issuer_certificate = ca_cert
85
+ cert.add_extension(ef.create_extension("basicConstraints", "CA:FALSE", true))
86
+ cert.add_extension(ef.create_extension("keyUsage", "digitalSignature,keyEncipherment", true))
87
+ cert.add_extension(ef.create_extension("extendedKeyUsage", "serverAuth"))
88
+ cert.add_extension(ef.create_extension("subjectAltName", "DNS:#{hostname}"))
89
+ cert.sign(ca_key, "SHA256")
90
+ [cert, key]
91
+ end
92
+
93
+ # Build an SSLContext whose SNI callback serves the right cert per host.
94
+ def server_context(dir = state_dir)
95
+ ca_cert, ca_key = load_ca(dir)
96
+ cache = CertCache.new(CACHE_SIZE)
97
+ ctx = OpenSSL::SSL::SSLContext.new
98
+ ctx.cert = ca_cert
99
+ ctx.key = ca_key
100
+ # ruby-openssl versions differ in how the callback receives its
101
+ # arguments: [[socket, name]] (one array arg), (socket, name), or
102
+ # (name). Flatten defensively — a raise inside the callback
103
+ # surfaces as an unrecognized-name handshake alert.
104
+ ctx.servername_cb = lambda do |*args|
105
+ host = Array(args).flatten.last.to_s.downcase
106
+ entry = cache.fetch(host) do
107
+ cert, key = mint_host(host, ca_cert, ca_key)
108
+ [cert, key]
109
+ end
110
+ entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
111
+ end
112
+ ctx
113
+ end
114
+
115
+ def trusted?(dir = state_dir)
116
+ paths = ca_paths(dir)
117
+ return false unless File.file?(paths[:cert])
118
+
119
+ marker = File.join(dir, "ca.trusted")
120
+ return false unless File.file?(marker)
121
+
122
+ Digest::SHA256.hexdigest(File.read(paths[:cert])).then do |fp|
123
+ File.read(marker).strip == fp
124
+ end
125
+ rescue SystemCallError
126
+ false
127
+ end
128
+
129
+ def mark_trusted(dir = state_dir)
130
+ require "digest"
131
+ paths = ca_paths(dir)
132
+ fp = Digest::SHA256.hexdigest(File.read(paths[:cert]))
133
+ File.write(File.join(dir, "ca.trusted"), "#{fp}\n")
134
+ end
135
+
136
+ def valid_pair?(cert_path, key_path)
137
+ return false unless File.file?(cert_path) && File.file?(key_path)
138
+
139
+ cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
140
+ cert.not_after > Time.now + (7 * 86_400) &&
141
+ cert.subject.to_s.include?(CA_COMMON_NAME)
142
+ rescue OpenSSL::OpenSSLError, SystemCallError, ArgumentError
143
+ false
144
+ end
145
+
146
+ # In-memory LRU for minted host certs.
147
+ class CertCache
148
+ def initialize(max)
149
+ @max = max
150
+ @store = {}
151
+ @order = []
152
+ @mutex = Mutex.new
153
+ end
154
+
155
+ def fetch(host)
156
+ @mutex.synchronize do
157
+ if @store.key?(host)
158
+ @order.delete(host)
159
+ @order << host
160
+ return @store[host]
161
+ end
162
+ value = yield
163
+ @store[host] = value
164
+ @order << host
165
+ if @order.length > @max
166
+ oldest = @order.shift
167
+ @store.delete(oldest)
168
+ end
169
+ value
170
+ end
171
+ end
172
+
173
+ def size
174
+ @mutex.synchronize { @store.size }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end