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 +7 -0
- data/CHANGELOG.md +119 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/bin/ask-local +6 -0
- data/lib/ask/local/certs.rb +179 -0
- data/lib/ask/local/cli/boot.rb +255 -0
- data/lib/ask/local/cli/context.rb +106 -0
- data/lib/ask/local/cli/routes.rb +253 -0
- data/lib/ask/local/cli/system.rb +294 -0
- data/lib/ask/local/cli.rb +104 -0
- data/lib/ask/local/config.rb +113 -0
- data/lib/ask/local/doctor.rb +135 -0
- data/lib/ask/local/errors.rb +20 -0
- data/lib/ask/local/framework.rb +77 -0
- data/lib/ask/local/hostname.rb +46 -0
- data/lib/ask/local/hosts.rb +80 -0
- data/lib/ask/local/inference.rb +144 -0
- data/lib/ask/local/log.rb +61 -0
- data/lib/ask/local/ownership.rb +62 -0
- data/lib/ask/local/ports.rb +47 -0
- data/lib/ask/local/proxy.rb +480 -0
- data/lib/ask/local/proxy_control.rb +199 -0
- data/lib/ask/local/resolver.rb +126 -0
- data/lib/ask/local/route_store.rb +150 -0
- data/lib/ask/local/runner.rb +240 -0
- data/lib/ask/local/sanitize.rb +43 -0
- data/lib/ask/local/supervisor.rb +244 -0
- data/lib/ask/local/trust.rb +123 -0
- data/lib/ask/local/variant.rb +136 -0
- data/lib/ask/local/version.rb +7 -0
- data/lib/ask/skills/ask-local/SKILL.md +89 -0
- data/lib/ask-local.rb +37 -0
- metadata +135 -0
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
|
+
[](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,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
|