diff --git a/.env.example b/.env.example index 7633de2..0650260 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,12 @@ WORMKEY_EDGE_URL=ws://localhost:3002/tunnel WORMKEY_PUBLIC_BASE_URL=http://localhost:3002 WORMKEY_EDGE_BASE_URL=ws://localhost:3002 -# Production control plane -# WORMKEY_PUBLIC_BASE_URL=https://wormkey.run +# Gateway (Subdomain Routing) +# Set your domain to enable wildcard subdomains (e.g. slug.wormkey.run) +WORMKEY_DOMAIN=wormkey.run + +# Production control plane (Management URLs) +# WORMKEY_PUBLIC_BASE_URL=https://t.wormkey.run # WORMKEY_EDGE_BASE_URL=wss://wormkey-gateway.onrender.com # Website: global mascot download counter (Upstash Redis) diff --git a/TODO.md b/TODO.md index d604ac7..577e022 100644 --- a/TODO.md +++ b/TODO.md @@ -34,9 +34,9 @@ The hard conceptual problem is solved. Now we structure the next phases so Wormk **Gateway changes:** - [x] Extract slug from `Host` header -- [ ] Remove cookie-based routing fallback (once wildcard live) -- [ ] Only use cookie for owner identity -- [ ] Remove query-based routing entirely once wildcard is live +- [x] Remove cookie-based routing fallback (once wildcard live) +- [x] Only use cookie for owner identity +- [x] Remove query-based routing entirely once wildcard is live This moves Wormkey from dev tool to internet product. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 35c752e..9e19ac6 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -17,7 +17,7 @@ export interface CreateSessionResponse { export async function createSession( controlPlaneUrl: string, - options: { port: number; auth?: boolean; expires?: string } + options: { port: number; auth?: boolean; expires?: string; forcePath?: boolean } ): Promise { const res = await fetch(`${controlPlaneUrl}/sessions`, { method: "POST", @@ -26,6 +26,7 @@ export async function createSession( port: options.port, authMode: options.auth ? "basic" : "none", expiresIn: options.expires ?? "24h", + forcePath: options.forcePath, }), }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6ad1f09..325bfed 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -83,6 +83,7 @@ program .command("http ") .description("Expose local port via wormhole") .option("--auth", "Enable basic auth (prints username/password)") + .option("--path", "Force path-based routing (/s/slug/)") .option("--expires ", "Session expiry (e.g. 30m, 1h, 24h)", "24h") .option("--control-plane ", "Control plane URL") .option("--edge ", "Edge tunnel URL") @@ -105,8 +106,8 @@ program : "wss://t.wormkey.run/tunnel"; const controlPlane = - process.env.WORMKEY_CONTROL_PLANE_URL ?? opts.controlPlane ?? + process.env.WORMKEY_CONTROL_PLANE_URL ?? defaultControlPlane; console.error("Control plane:", controlPlane); @@ -114,6 +115,7 @@ program port: portNum, auth: opts.auth, expires: opts.expires, + forcePath: opts.path, }); if (opts.auth && session.username && session.password) { @@ -124,8 +126,8 @@ program } const edgeUrl = - process.env.WORMKEY_EDGE_URL ?? opts.edge ?? + process.env.WORMKEY_EDGE_URL ?? session.edgeUrl ?? defaultEdge; console.error("Edge tunnel:", edgeUrl); diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index d99bb1c..fa0043d 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -8,7 +8,7 @@ import cors from "@fastify/cors"; const ADJECTIVES = [ "quiet", "bold", "swift", "calm", "bright", "soft", "warm", "cool", - "deep", "flat", "wild", "mild", "dark", "pale", "pure", "rare", "max" , + "deep", "flat", "wild", "mild", "dark", "pale", "pure", "rare", "max", ]; const NOUNS = [ "lime", "mint", "sage", "rose", "sky", "sea", "sand", "snow", @@ -80,11 +80,11 @@ async function main() { } fastify.post<{ - Body: { port?: number; authMode?: string; expiresIn?: string }; + Body: { port?: number; authMode?: string; expiresIn?: string; slug?: string; forcePath?: boolean }; }>("/sessions", async (req, reply) => { - const { port = 3000, authMode = "none", expiresIn = "24h" } = req.body ?? {}; + const { port = 3000, authMode = "none", expiresIn = "24h", slug: requestedSlug, forcePath = false } = req.body ?? {}; - const slug = randomSlug(); + const slug = requestedSlug || randomSlug(); const ownerToken = randomToken(); const sessionToken = `${slug}.${ownerToken}`; const sessionId = `sess_${randomToken()}`; @@ -92,10 +92,18 @@ async function main() { // All URLs derived from env (canonical origin). Never use request host. const publicBase = PUBLIC_BASE_URL.replace(/\/$/, ""); const edgeBase = EDGE_BASE_URL.replace(/\/$/, ""); - const publicUrl = `${publicBase}/s/${slug}`; + + // Choose URL format: Subdomain by default if domain is set, unless forcePath is specified + const domain = process.env.WORMKEY_DOMAIN; + let publicUrl = `${publicBase}/s/${slug}`; + if (domain && !forcePath) { + const protocol = publicBase.split(":")[0]; + publicUrl = `${protocol}://${slug}.${domain}`; + } + const edgeUrl = `${edgeBase}/tunnel`; - const ownerUrl = `${publicBase}/.wormkey/owner?slug=${slug}&token=${ownerToken}`; - const overlayScriptUrl = `${publicBase}/.wormkey/overlay.js?slug=${slug}`; + const ownerUrl = `${publicUrl}/.wormkey/owner?token=${ownerToken}`; + const overlayScriptUrl = `${publicUrl}/.wormkey/overlay.js`; const expiresMs = expiresIn.endsWith("m") diff --git a/packages/gateway/main.go b/packages/gateway/main.go index 6b4c602..d5bab3a 100644 --- a/packages/gateway/main.go +++ b/packages/gateway/main.go @@ -4,10 +4,10 @@ package main import ( - _ "embed" "bufio" "bytes" "crypto/rand" + _ "embed" "encoding/binary" "encoding/hex" "encoding/json" @@ -185,18 +185,38 @@ func randomSecret(n int) string { } // extractSlugFromHost extracts slug from host like "quiet-lime-82.wormkey.run" or "quiet-lime-82.wormkey.run:3002". -// Assumes format: slug.wormkey.run (at least 3 dot-separated parts). func extractSlugFromHost(host string) string { host = strings.Split(host, ":")[0] // strip port - parts := strings.Split(host, ".") - if len(parts) < 3 { + domain := os.Getenv("WORMKEY_DOMAIN") + if domain == "" { + // Fallback for local development if domain not set: assumes format slug.wormkey.run (at least 3 parts) + parts := strings.Split(host, ".") + if len(parts) >= 3 { + return parts[0] + } + return "" + } + + // Wildcard: support both slug.domain and subdomains of subdomains if domain is set correctly + domain = strings.Split(domain, ":")[0] // strip port from domain + if !strings.HasSuffix(host, "."+domain) { return "" } - return parts[0] + return strings.TrimSuffix(host, "."+domain) } func resolveSlug(r *http.Request) string { - // 1. Path-based: /s/:slug (no wildcard TLS needed) + // 1. Query-based (Highest priority for management tools/claims) + if s := r.URL.Query().Get("slug"); s != "" { + return s + } + + // 2. Host-based (Primary for tunnels) - e.g. slug.wormkey.run + if s := extractSlugFromHost(r.Host); s != "" { + return s + } + + // 3. Path-based (Legacy fallback) - e.g. wormkey.run/s/slug if strings.HasPrefix(r.URL.Path, "/s/") { rest := r.URL.Path[3:] // skip "/s/" idx := strings.Index(rest, "/") @@ -218,22 +238,7 @@ func resolveSlug(r *http.Request) string { return slug } } - // 2. Query fallback (?slug=) - slug := r.URL.Query().Get("slug") - if slug != "" { - return slug - } - // 3. Cookie (for asset requests like /_next/... or /assets/...) - if c, err := r.Cookie("wormkey_slug"); err == nil && c.Value != "" { - return c.Value - } - if c, err := r.Cookie("wormkey"); err == nil && c.Value != "" { - return c.Value - } - // 4. Host-based (slug.wormkey.run) - if s := extractSlugFromHost(r.Host); s != "" { - return s - } + return "" } @@ -520,10 +525,8 @@ func main() { http.Error(w, "Invalid owner token", 401) return } - setCookie(w, "wormkey_slug", slug, false) - setCookie(w, "wormkey", slug, false) setCookie(w, "wormkey_owner", token, true) - http.Redirect(w, r, "/s/"+slug, http.StatusFound) + http.Redirect(w, r, "/", http.StatusFound) }) mux.HandleFunc("/.wormkey/me", func(w http.ResponseWriter, r *http.Request) { @@ -848,10 +851,6 @@ func handleTunnel(tunnels *sync.Map, closedSlugs *sync.Map, controlPlaneURL stri sc.w.Header().Set(k, v) } } - if sc.setCookie != "" { - // wormkey_slug ensures asset requests (/_next/..., /assets/...) route correctly - sc.w.Header().Add("Set-Cookie", "wormkey_slug="+sc.setCookie+"; Path=/; SameSite=Lax") - } sc.w.WriteHeader(status) if sc.flusher != nil { sc.flusher.Flush() @@ -887,7 +886,6 @@ func handleTunnel(tunnels *sync.Map, closedSlugs *sync.Map, controlPlaneURL stri func handleProxy(tunnels *sync.Map, controlPlaneURL string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - slugFromPath := strings.HasPrefix(r.URL.Path, "/s/") slug := resolveSlug(r) if slug == "" { writeInvalidSlug(w) @@ -965,16 +963,12 @@ func handleProxy(tunnels *sync.Map, controlPlaneURL string) http.HandlerFunc { } done := make(chan struct{}) flusher, _ := w.(http.Flusher) - setCookie := "" - if slugFromPath || r.URL.Query().Get("slug") != "" || extractSlugFromHost(r.Host) == slug { - setCookie = slug - } respW := http.ResponseWriter(w) if owner { respW = &overlayInjectWriter{w: w, slug: slug} } tc.activeStreams.Add(1) - tc.streams.Store(streamID, &streamCtx{w: respW, done: done, flusher: flusher, setCookie: setCookie}) + tc.streams.Store(streamID, &streamCtx{w: respW, done: done, flusher: flusher}) sendStreamEnd := func() { f := make([]byte, 5) f[0] = FrameStreamEnd diff --git a/packages/gateway/overlay.js b/packages/gateway/overlay.js index afc4c28..4e744c1 100644 --- a/packages/gateway/overlay.js +++ b/packages/gateway/overlay.js @@ -10,6 +10,9 @@ return s ? new URL(s).origin : window.location.origin; })(); function getSlug(){ + var host = window.location.hostname; + var parts = host.split('.'); + if (parts.length >= 3) return parts[0]; var s = new URLSearchParams(window.location.search).get('slug'); if (s) return s; var m = window.location.pathname.match(/^\/s\/([^\/]+)/); diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 7a35e47..cc07a42 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -59,7 +59,7 @@ export default function RootLayout({ {children} {process.env.NODE_ENV === "development" && } {/* - */} +