Repository navigation
feat: share localhost (whk share) and live tunnels (whk tunnel --live) - #644
Draft
kroqdotdev wants to merge 18 commits into
Draft
kroqdotdev wants to merge 18 commits into
kroqdotdev wants to merge 18 commits into
Conversation
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>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
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. |
Dependency ReviewThe following issues were found:
|
|
|
||
| #[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()); |
| 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")); |
| use super::*; | ||
| use http::HeaderValue; | ||
|
|
||
| const KEY: &[u8] = b"test secret"; |
| "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)); |
| 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:?}"); |
| 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:?}"); |
…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()); |
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onsharehooks.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 --liverelays 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).apps/edge-rs, new) serves previews and relays live captures. The CLI and edge share the protocol cratecrates/tunnel. The edge holds no account logic: it asks the web app over/api/edge/*(EDGE_SECRET)./reportform (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.client.shares,client.names,share()), MCP toolsshare_port(local server only),list_shares,stop_share,share_requestsand the name tools, andliveResponsein the TypeScript, Python and Go SDKs. No dashboard step is needed beyond approving the CLI or MCP connection./docs/share,/docs/live-tunnel, the ngrok comparison, privacy, terms, subprocessors and AGENTS.md.Limits are rows in
plan_entitlements: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.sqland00092_live_capture.sqlgo on production before the release that needs them. 00092 keepscapture_webhook()'s signature, so the running receiver keeps working.New production settings:
EDGE_SECRET,EDGE_URL,SHARE_PREVIEW_ORIGIN,TURNSTILE_SITE_KEY,TURNSTILE_SECRET_KEY,WEB_RISK_API_KEYEDGE_RELAY_URL,EDGE_SECRETWithout 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
tunnels,tunnels-abuse,foundations) against the dev stackcargo testand clippy for the edge (135), the tunnel crate, the CLI (209) and the receiver (349)🤖 Generated with Claude Code