Skip to content

feat: share localhost (whk share) and live tunnels (whk tunnel --live) - #644

Draft
kroqdotdev wants to merge 18 commits into
mainfrom
feat/sharehooks
Draft

kroqdotdev wants to merge 18 commits into
mainfrom
feat/sharehooks

Conversation

@kroqdotdev

@kroqdotdev kroqdotdev commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Sharing a local site and live tunnels, built into webhooks.cc and bundled into every plan:

  • whk share <port> puts a local site at a public link on sharehooks.cc (live reload works), with reserved names and passwords on paid plans. Free previews get random names ending in -free, last 2 hours, and show visitors a notice first.
  • whk tunnel --live relays each captured webhook to the local app while the sender waits, so the sender gets the app's real answer, which is stored with the request (liveResponse).
  • Tunnel edge (apps/edge-rs, new) serves previews and relays live captures. The CLI and edge share the protocol crate crates/tunnel. The edge holds no account logic: it asks the web app over /api/edge/* (EDGE_SECRET).
  • Web app: the control plane, Account > Shares, a public /report form (Turnstile, EU DSA art. 16), Sharing in the ops center (reports, stops, blocked names and words, a Web Risk reputation check), and /.well-known/security.txt.
  • Receiver: relays live captures after the capture is stored. 502 when the CLI is offline, 504 after 25 seconds; relayed paths are pinned under their own endpoint.
  • SDKs and MCP: sharing in the TypeScript SDK (client.shares, client.names, share()), MCP tools share_port (local server only), list_shares, stop_share, share_requests and the name tools, and liveResponse in the TypeScript, Python and Go SDKs. No dashboard step is needed beyond approving the CLI or MCP connection.
  • Docs: /docs/share, /docs/live-tunnel, the ngrok comparison, privacy, terms, subprocessors and AGENTS.md.

Limits are rows in plan_entitlements:

Plan Shares at once Reserved names Session Data a month
Free 1 none 2 h 1 GB
Pro 3 3 none 25 GB
Business 10 20 none 150 GB

Team plans give Pro's or Business's numbers per member.

Before merging and deploying

  • Draft until the edge's production box exists (a Hetzner Arm box on the private network) and the ops repository can deploy it.

  • Migrations 00091_tunnels.sql and 00092_live_capture.sql go on production before the release that needs them. 00092 keeps capture_webhook()'s signature, so the running receiver keeps working.

  • New production settings:

    • web: EDGE_SECRET, EDGE_URL, SHARE_PREVIEW_ORIGIN, TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, WEB_RISK_API_KEY
    • receiver: EDGE_RELAY_URL, EDGE_SECRET
    • edge: its own env, including the private words file.

    Without them, sharing stays off and the receiver behaves as before.

  • CLI, SDK and MCP versions are not bumped here; they are released separately.

Test plan

  • Web unit tests (1613), the tunnel integration suites (tunnels, tunnels-abuse, foundations) against the dev stack
  • cargo test and clippy for the edge (135), the tunnel crate, the CLI (209) and the receiver (349)
  • SDK (908), MCP, Python (842) and Go suites; typecheck and lint
  • End to end on the dev stack: shares, names, passwords, the Free notice and slot limit, stop, and live relay (answered, offline 502, timeout 504)
  • Staging through Cloudflare on the real sharehooks.cc (homelab LXC): the control channel over wss, a page with the Report button over HTTP/2 with HSTS, a WebSocket echo, the Free notice, the bare-domain redirect
  • Three rounds of independent review (adversarial security on the edge and relay, authorization and SQL concurrency, end-to-end edge cases, the ops PR), every finding fixed: fe6ea22, e45d31c, 0a2239c, 6d3588d, b29cfcd, 9ff8a88, 70a66bc, 1b9af37
  • Round-2 fixes retested end to end on staging through Cloudflare (all nine reported problems confirmed fixed)
  • Agent-ready: everything on the Shares page is reachable from the API, CLI and MCP (57 local tools, 54 remote)
  • arm64 image build and deploy through the ops repository

🤖 Generated with Claude Code

kroqdotdev and others added 10 commits November 10, 2026 19:01
apps/edge-rs is the service that serves previews on the sharehooks
domain and relays live captures: a port of ouicu's edge without
uploaded sites, last versions, custom domains, client allowlists or
visitor arrivals. The CLI connects over one WebSocket carrying yamux
streams (crates/tunnel, MIT, shared with whk). The edge asks the web
app's /api/edge routes whether a share may open and hands over usage
every sync; its control port takes stops, password changes and
request-log reads; its relay port takes live captures from the
receiver. Phishing brand and lure words load from EDGE_WORDS_FILE, so
the production list stays off the public repository.

CI tests and lints the edge and the crate, and builds the edge image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 00091 adds the tunnel limits to plan_entitlements (shares at
once, session length, data a month, reserved names, passwords), the
tunnel_names, tunnel_shares, tunnel_usage and abuse_reports tables,
users.paid_since for the new-account notice, endpoints.live_session_id
and requests.live_response. RLS is on with no client policies, and the
functions that open shares and apply usage batches are service-role
only.

The edge talks to /api/edge/{start,open,close,sync,unlock,flag} with
EDGE_SECRET. Users list and stop shares and reserve names through
/api/shares and /api/tunnel-names, with audit events. A user's tunnel
plan is the best of their own plan and their subscribed teams'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
whk share <port> opens a preview through the tunnel edge (WHK_TUNNEL_URL,
wss://tunnel.webhooks.cc by default) with an optional reserved name and
password, a QR code, JSON events and quiet reconnects. whk shares lists
and stops previews, whk names reserves names and sets their passwords.
whk tunnel --live opens a live session next to the endpoint, so the
sender gets the local app's real answer; emails still arrive through
the stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…igration 00092)

While whk tunnel --live runs, capture_webhook() reports the endpoint as
live, and the receiver, after storing the capture and releasing its
connection, sends the request to the edge's relay port and answers the
sender with the app's own response. The CLI not connected gives 502, no
answer within 25 seconds 504; the request is captured either way. Live
relays have their own cap per account (LIVE_MAX_INFLIGHT_PER_ACCOUNT).
record_live_response() keeps the status, headers and the first 64 KB of
the answer on the request, which the requests API returns as
liveResponse and the request view shows in a Response tab. Without
EDGE_RELAY_URL the receiver behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Account > Shares lists live previews with copy, open, a QR code and
stop, a request log polled while it is open, reserved names with
passwords and team names, earlier shares, and the month's data against
the plan. The plan tables gain the sharing rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/report takes reports about previews from anyone, as the EU Digital
Services Act asks, behind Turnstile and 5 reports an hour per address;
staff are alerted without the report's contents and the reporter gets
a receipt. The ops center gains Sharing: reports (block the name,
suspend sharing, resolve, statements of reasons), every live share
with stop, blocked names and words, and the reputation results. A
daily Google Web Risk lookup of the preview domain opens a report on a
listing. /.well-known/security.txt points at the report form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in every SDK

The TypeScript SDK gains client.shares (list, stop, requests, start),
client.names, and share(), which runs whk share and resolves with the
link. The MCP server gains share_port (local server only), list_shares
and stop_share, and the agent skill says when to share and when to use
whk tunnel --live. Requests carry liveResponse in the TypeScript, Python
and Go SDKs. whk takes an API key from WHK_API_KEY, which share() and
share_port pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/docs/share and /docs/live-tunnel describe whk share and whk tunnel
--live with the plan limits; the CLI, MCP and plans pages link them and
the tool counts move to 51 local and 48 remote. The ngrok comparison
gains sharing rows from ngrok's own pages (checked 2026-11-10). Privacy,
terms and subprocessors cover previews: the access log, connecting
addresses, cookies, acceptable use, reports and takedowns. AGENTS.md
records the tunnel decisions, and the changelog has the release entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The relay URL was built from the path the sender wrote after the slug,
and URL parsing resolves `..` and `%2e%2e` segments, so a path such as
`/../<other id>/x` would have reached another endpoint's live session
with the relay secret. The receiver now parses the URL it built and
relays only when the path still sits under the endpoint's own prefix;
otherwise the sender gets 400 and the request is recorded with the new
outcome `refused`.

The SDKs' LiveResponse now match what the API sends: status and ms may
be null, and outcome says why a stand-in answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… preview

A staging edge has only the preview domain, so the CLI connects to
tunnel.<preview domain>. The edge took that host for a preview named
tunnel and answered /v1/connect with 404. The label is reserved by the
web app anyway, so the edge now never treats it as a preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kroqdotdev kroqdotdev added the release: minor Ships a significant feature: the next web release bumps the minor version label Nov 10, 2026
@coderabbitai

coderabbitai Bot commented Nov 10, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Dependency Review

The following issues were found:

  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ✅ 0 package(s) with unknown licenses.
  • ⚠️ 12 packages with OpenSSF Scorecard issues.

View full job summary


#[test]
fn passwords_go_as_hex_and_are_checked() {
assert_eq!(password_header("pässword"), "70c3a47373776f7264");
#[test]
fn passwords_go_as_hex_and_are_checked() {
assert_eq!(password_header("pässword"), "70c3a47373776f7264");
assert!(password_problem("short").is_some());
Comment thread apps/cli-rs/src/share/tests.rs Fixed
assert_eq!(header(HEADER_NAME), "acme");
assert_eq!(header(HEADER_PORT), port.to_string());
assert_eq!(header(HEADER_CLIENT), "laptop, Linux");
assert_eq!(header(HEADER_PASSWORD), password_header("open sesame"));
Comment thread apps/edge-rs/src/gate.rs
use super::*;
use http::HeaderValue;

const KEY: &[u8] = b"test secret";
Comment thread apps/edge-rs/src/gate.rs
"another name"
);
assert!(
!has_pass(&h, b"other key", "studio", "v1", false, NOW),
mod tests {
use super::*;

const KEY: &[u8] = b"test secret";
!has_seen(&h, KEY, "other-name", false, NOW),
"bound to the share"
);
assert!(!has_seen(&h, b"another key", "calm-otter", false, NOW));
Comment thread apps/edge-rs/src/pace.rs
let one = Paces::new(Pace::new(rate(1000 * KIB, 0)), account.clone());
let other = Paces::new(Pace::new(rate(1000 * KIB, 0)), account);
let first = one.take(100 * KIB, now);
assert!((first.as_secs_f64() - 1.0).abs() < 0.001, "{first:?}");
Comment thread apps/edge-rs/src/pace.rs
assert!((first.as_secs_f64() - 1.0).abs() < 0.001, "{first:?}");
// Another share of the same account waits behind the first.
let second = other.take(100 * KIB, now);
assert!((second.as_secs_f64() - 2.0).abs() < 0.001, "{second:?}");
kroqdotdev and others added 5 commits November 10, 2026 19:54
…e answers and early stops

- The edge looked for <head by copying and rescanning everything held on
  every frame, so a page sent one byte at a time cost seconds of CPU.
  The search now looks at each byte once, case-insensitively, and gives
  up after 4096 frames.
- A 3xx counted as a redirect, and skipped the web-pages-only checks,
  even without a Location header, which browsers render or download.
  Only 301, 302, 303, 307 and 308 with Location skip them now, and never
  with Content-Disposition: attachment.
- The receiver read a live answer of up to 10 MB into memory before
  answering. It now streams the answer to the sender and keeps only the
  first 64 KB to record; live relays also have a cap across all accounts
  (LIVE_MAX_INFLIGHT_TOTAL, default 256). Live answers on the capture host
  carry nosniff and a sandbox CSP.
- A stop that reached the edge while the tunnel was still opening was
  lost until the next sync. The edge now remembers it for two minutes
  and closes the session as it registers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- whk tunnel --live counted every team the user belongs to, so a member
  of a team whose subscription ended could still relay its shared
  endpoints. It now uses the same access rule as the dashboard.
- Runs outlived the access they were opened with. Removing a member,
  unsharing an endpoint, ending a team or its subscription, disconnecting
  an agent and deleting an account now stop the runs that lose access at
  once, and every edge sync checks open runs again in one query
  (tunnel_runs_without_access()). Agent runs are judged by their
  registration, not the short-lived token that opened them.
- A name shaped like a random preview name could be reserved by someone
  else once the share ended, taking over its link. Reserved names may no
  longer look drawn.
- Deleting an account deleted its runs, and with them the evidence a
  report needs. Runs now keep the address for the retention period with
  no owner.
- The refused live outcome read as answered, suspended accounts could
  still reserve names and set passwords, and a blocked free name could
  be drawn again.
- The 30-second sync made a query per preview and per account; it now
  makes a fixed few.
- share_port started a second share on the same port without stopping
  the first.
- In production the report form refuses to run without Turnstile keys
  instead of skipping the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…list, sockets and live answers

- Content checks: hop-by-hop headers are stripped before the check and
  Connection can no longer remove Content-Type, Content-Length,
  Content-Encoding or Content-Disposition; two Content-Type headers or a
  comma list are refused; a 3xx counts as a redirect only with one
  non-empty Location.
- The Report button and phishing scan run on every page answer (any
  status, HTML and XHTML; SVG scanned), and compressed pages are decoded
  on page visits instead of skipping both. Other encodings are refused.
- badge.js no longer carries the brand and lure words: it reports the
  title and field kinds of pages with a password or card field, and the
  edge matches them against its private list. Flag posts are rate
  limited per visitor.
- WebSockets stop at the account's allowance and the visitor's daily cap,
  idle sockets end after 10 minutes, slow request bodies fail, a visitor
  may hold 16 sockets per share, and IPv6 visitors count per /56.
- The visitor notice cookie is bound to the CLI's run, not the
  connection, so reconnects do not show it again.
- Live answers: only API-shaped types, no downloads, at most 1 MB (cut
  off as an error past it, recorded as refused). The receiver records the
  headers the sender got and passes X-Forwarded-Host, -Proto and -Prefix
  to the app so it can rebuild the public URL for signature checks.
- The line the CLI shows for a visitor matches the status they got.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… changes and live grace

- A deleted account's runs no longer fail the usage batch (which blocked
  every sync until the edge gave up on it), and one bad batch never holds
  up the rest.
- Reconnecting keeps a Free share's first expiry, so the 2-hour limit
  cannot be reset.
- A member who leaves or is removed hands their team names to the owner;
  team names work only for current members of a subscribed team, and
  passwords follow the same rule as release.
- Each sync applies the current plan to running shares: shares over the
  slot count, run passwords the plan lacks, names past its count, and a
  time limit for runs opened without one end as "plan changed".
- Endpoint owners see and stop teammates' live tunnels on their
  endpoints. Suspended accounts and revoked keys end their runs at the
  next sync; deleting an endpoint ends its live run instead of deleting
  the row; releasing a name stops shares under it; implicit reserves are
  audited.
- A live endpoint keeps answering 502 for 2 minutes after whk stops or
  drops, so a sender in the gap retries instead of getting the mock.
- Clearer wording, and ended runs are listed once per run, not per
  reconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…docs

- whk share, whk tunnel and --live take the same target forms; https
  targets and paths in a share are refused with a clear message, and
  127.0.0.1 or [::1] connect to exactly that address.
- SIGTERM ends like Ctrl-C, and a share stopped elsewhere or by its time
  limit ends as stopped with exit 0; a refused --live exits non-zero.
  whk share warns when nothing answers on the port yet.
- API errors print the server's words without status suffixes.
- WHK_AUTH=agent makes whk use the agent credential file; the SDK's
  share() uses it for agent clients so a share belongs to the account
  the SDK sees, stops by closing stdin, and gives install help per
  platform.
- The docs match what ships: the notice button, name reserving, plan
  changes, the live grace, what reaches the sender, the X-Forwarded
  headers for signature checks, and exit codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
assert_eq!(password_header("pässword"), "70c3a47373776f7264");
assert!(password_problem("short").is_some());
assert!(password_problem(&"x".repeat(201)).is_some());
assert!(password_problem("long enough").is_none());
kroqdotdev and others added 3 commits November 10, 2026 21:03
… XML pages and relayed headers

- Brotli streams with windows over 4 MiB are refused before decoding,
  and page decodes are capped at 8 per account and 64 on the edge, so a
  tiny body cannot pin megabytes per response.
- Every Content-Encoding header and list item is read; anything but none
  or one known coding is refused on page visits.
- XML pages (text/xml, application/xml, +xml) are decoded and scanned for
  phishing like SVG.
- A followed redirect's body is cut to 16 KB.
- The receiver records 204, 304, HEAD and empty answers as complete,
  counts only time spent waiting on the visitor as a slow body, strips
  Forwarded, X-Real-IP and similar headers a sender could use to steer
  the app, sets X-Forwarded-For to the sender address it trusts, and takes
  X-Forwarded-Proto and Host from the public capture URL. The CLI hears
  when a live answer was cut off past 1 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and downgrades; agent-ready shares API

- Team names count against the team (the plan's names per seat times its
  seats), own names against the person, so handing a leaver's names to
  the owner can no longer push the owner's own names past their plan.
- Transient database errors in a usage batch fail the sync so the edge
  resends it, instead of dropping the counts.
- A preview that no longer matches the plan (opened paid and now Free,
  without the notice or a time limit the plan now has) ends as "plan
  changed", so a restart gets the right name, notice and limit.
- GET /api/shares gives everything the Shares page shows, with next
  steps as whk commands (hints, names standing, suspension, why data is
  paused), and /api/tunnel-names gives own and team counts, so an agent
  never needs the dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hboard

An agent should never need the dashboard beyond approving the CLI or MCP
connection. The MCP server gains share_requests, list_names,
reserve_name, release_name, set_name_password and clear_name_password
(57 tools locally, 54 on the remote server), and share_port and
list_shares return the plan's limits, usage and next steps. The CLI
gains whk shares log, plan and name standing in whk shares ls and whk
names ls, and whk names reserve --team. A key revoked while the CLI
reconnects ends the share as stopped; unsupported schemes get a clear
message. The docs gain a "For agents" section and match what ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release: minor Ships a significant feature: the next web release bumps the minor version

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants