server+native: 11ch.at short-link redirector for message permalinks (#905) (#1001)
`lchat links run` — a subcommand of the main binary, not a separate sibling service — fronts the shared 11ch.at domain: a bare apex request bounces to elevenmessenger.com, and /l/<host> live-checks the target (SSRF-guarded fetch of its own /api/version, reusing unfurl.go's guardDialControl) before redirecting, caching the result in memory (7-day fresh / 1-hour retry-on-fail TTLs, mirroring unfurl.go's own preview cache) so the check costs roughly one fetch per host per window rather than one per click. No registry, no per-instance opt-in — see the design discussion on #905 for why an earlier self-registration draft was replaced by this. The redirect never puts a fragment in Location, so a caller's own #l=<token> (the message-permalink fragment, links.go, #907) survives the hop — browsers reattach the original request's fragment when the target doesn't specify one. Also serves the /l/* apple-app-site-association and registers 11ch.at as an Associated Domain in the iOS entitlements, so a tapped permalink opens the app directly instead of Safari. Not yet deployed to the router box (no DNS/ cert/systemd unit) — mintMessageLink still emits instance-origin links until that lands; flipping its URL template is the one remaining step.
Co-authored-by: Claude <noreply@anthropic.com>
7 files changed,
+490
−3
CLAUDE.md+6 −0docs/planet-scale-guide/processes.html+5 −3ios/LChat/LChat-Debug.entitlements+5 −0ios/LChat/LChat.entitlements+5 −0linkscmd.go+254 −0linkscmd_test.go+212 −0main.go+3 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index 5cb6d31..84b3bc3 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -143,6 +143,12 @@ in, so deploy is one file (no cgo). |
| reference implementation, kept on purpose** (a runnable example + the wire-format |
| illustration the Go tests pin against) — don't delete it or let it rot. |
| - `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache). |
| +- `linkscmd.go` — `lchat links run`, the `11ch.at` short-link redirector for |
| + message permalinks (issue #905): a bare apex request bounces to |
| + `elevenmessenger.com`; `/l/<host>` live-checks the target (SSRF-guarded fetch |
| + of its own `/api/version`, cached in memory, no database) before |
| + redirecting. A subcommand of this binary, not a separate sibling service |
| + like push/home — see `links.go` for the message-permalink tokens it fronts. |
| - `controld.go` / `cloud.go` / `reaper.go` — self-serve space creation on a |
| router box: a root-only local control daemon that executes `add-instance` (with |
| a capacity cap), and the public, unprivileged provisioner in front of it (caller |
diff --git a/docs/planet-scale-guide/processes.html b/docs/planet-scale-guide/processes.html| index fa355c1..0ecfcd3 100644 |
| --- a/docs/planet-scale-guide/processes.html |
| +++ b/docs/planet-scale-guide/processes.html |
| @@ -40,6 +40,7 @@ |
| <tr><td><code>lchat-bot@<host>.service</code></td><td>The optional bot sidecar for one instance (template unit).</td><td><code>/opt/lchat/live/<host> bot run</code></td><td><code>lchat</code></td></tr> |
| <tr><td><code>lchat-home.service</code></td><td>The home server: the anonymous, passkey-gated backup store (a separate binary from the same repository).</td><td><code>/opt/lchat/home --addr /run/lchat/home.sock --db /data/home.db</code></td><td><code>lchat</code></td></tr> |
| <tr><td><code>lchat-push.service</code></td><td>The push relay: the central Apple push-notification signer (separate binary).</td><td><code>/opt/lchat/push --addr /run/lchat/push.sock --db /data/push.db</code></td><td><code>lchat</code></td></tr> |
| + <tr><td><code>lchat-links.service</code></td><td>The <code>11ch.at</code> short-link redirector for message permalinks (issue #905) — unlike home/push, a subcommand of the main <code>lchat</code> binary, not a separate one; holds no database (an in-memory, TTL-bounded verification cache only). <span class="status partial">built, not yet deployed</span></td><td><code>/opt/lchat/lchat links run --socket /run/lchat/links.sock</code></td><td><code>lchat</code></td></tr> |
| <tr><td><code>lchat-control.service</code></td><td>The control daemon: the root-only local API that creates and deletes instances. See <a href="spaces.html#controld">part 05</a>.</td><td><code>/opt/lchat/lchat controld</code></td><td>root (deliberately)</td></tr> |
| <tr><td><code>lchat-cloud.service</code></td><td>The cloud provisioner: the public, unprivileged self-serve space-creation service. See <a href="spaces.html#cloud">part 05</a>.</td><td><code>/opt/lchat/lchat cloud --socket /run/lchat/cloud.sock --db /data/cloud.db</code></td><td><code>lchat</code></td></tr> |
| <tr><td><code>lchat-update.timer</code> + <code>.service</code></td><td>The hourly auto-updater (a timer that fires a one-shot pass). See <a href="deploys.html#auto-updates">part 04</a>.</td><td><code>/opt/lchat/lchat update run</code></td><td>root (deliberately)</td></tr> |
| @@ -145,13 +146,14 @@ curl -s --unix-socket /run/lchat/chat-a.example.com.sock http://localhost/api/ve |
| <p>Everything runs as a locked-down, unprivileged user except the two processes whose job is administration itself, and those two never talk to the internet. If an instance is somehow compromised, the sandbox means it cannot read other instances’ secrets, cannot touch the software store, and cannot escalate.</p> |
| </div> |
| -<h2 id="sibling-services">The sibling services: home and push</h2> |
| -<p>Two services deploy next to the messenger but are not chat instances, and the router treats them as plain <code>service</code> routes — names it forwards traffic to, whose lifecycle it does not manage:</p> |
| +<h2 id="sibling-services">The sibling services: home, push, and links</h2> |
| +<p>Three services deploy next to the messenger but are not chat instances, and the router treats them as plain <code>service</code> routes — names it forwards traffic to, whose lifecycle it does not manage:</p> |
| <ul> |
| <li><strong>The home server</strong> (<code>lchat-home.service</code>) is the account-recovery backup store: members’ encrypted key backups, gated by passkeys. Its one operational constraint is that <strong>its hostname must never change</strong> — the hostname is part of the passkey security model (the WebAuthn “relying party” origin), and changing it orphans every passkey. Its environment file <code>/data/home.env</code> is <em>required</em> (no leading <code>-</code> in the unit) because a misconfigured home server is worse than a stopped one.</li> |
| <li><strong>The push relay</strong> (<code>lchat-push.service</code>) holds the one Apple push-notification signing key for the whole fleet, so that individual instances never do. Instances authenticate to it with per-instance revocable tokens. Its environment file <code>/data/push.env</code> is likewise required.</li> |
| + <li><strong>The links redirector</strong> (<code>lchat-links.service</code>, <span class="status partial">built, not yet deployed</span>) fronts <code>11ch.at</code>, the shared short-domain for message permalinks (issue #905 — <code>linkscmd.go</code>): a bare request to the apex bounces to <code>elevenmessenger.com</code>, and <code>/l/<host></code> live-checks the target (an SSRF-guarded fetch of its own <code>/api/version</code>, cached in memory) before redirecting. Unlike home and push it is a <em>subcommand</em> of the main <code>lchat</code> binary rather than a separate one, and it holds no database at all — a restart just re-populates its verification cache from scratch, which is cheap because entries are looked up lazily.</li> |
| </ul> |
| -<p>Both are registered with the router by <code>lchat add-service <host> --socket <path></code>, which simply adds a <code>service</code> row to the registry after checking the backend answers. Deploying either is manual and rare: copy the new binary over the old one and <code>systemctl restart</code> the unit (<a href="deploys.html#sibling-deploys">part 04</a>).</p> |
| +<p>All three are registered with the router by <code>lchat add-service <host> --socket <path></code>, which simply adds a <code>service</code> row to the registry after checking the backend answers. Deploying home or push is manual and rare: copy the new binary over the old one and <code>systemctl restart</code> the unit (<a href="deploys.html#sibling-deploys">part 04</a>); links, being a subcommand of the main binary, updates whenever <code>/opt/lchat/lchat</code> itself does.</p> |
| <h2 id="bot-sidecars">Bot sidecars: a member that runs on a server</h2> |
| <p>An instance can have a <strong>bot sidecar</strong>: a process hosting the built-in helper personas (Claude and Eleven). Architecturally, a bot is an ordinary end-to-end encrypted <em>member</em> of the chat whose “device” happens to be a server process: it holds its own private keys (in the session files under <code>/data/<host>-bot/</code>) and connects to its instance as a WebSocket <em>client</em>, over the instance’s public hostname, exactly like a browser would. It serves no inbound traffic at all — no socket, no port, no route.</p> |
diff --git a/ios/LChat/LChat-Debug.entitlements b/ios/LChat/LChat-Debug.entitlements| index 262a358..4ca8c90 100644 |
| --- a/ios/LChat/LChat-Debug.entitlements |
| +++ b/ios/LChat/LChat-Debug.entitlements |
| @@ -19,6 +19,11 @@ |
| <key>com.apple.developer.associated-domains</key> |
| <array> |
| <string>applinks:*.elevenmessenger.com</string> |
| + <!-- Message permalinks (issue #905): 11ch.at is the one shared short |
| + domain every instance's "Copy link" can route through, since a |
| + dynamically-created instance host can't be listed here ahead of |
| + time. Server side: linkscmd.go (lchat links run). --> |
| + <string>applinks:11ch.at</string> |
| <!-- "Sign in with Eleven": native passkeys bind to the home server, |
| which serves the matching apple-app-site-association. --> |
| <string>webcredentials:home.elevenmessenger.com?mode=developer</string> |
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements| index add067d..b10443a 100644 |
| --- a/ios/LChat/LChat.entitlements |
| +++ b/ios/LChat/LChat.entitlements |
| @@ -19,6 +19,11 @@ |
| <key>com.apple.developer.associated-domains</key> |
| <array> |
| <string>applinks:*.elevenmessenger.com</string> |
| + <!-- Message permalinks (issue #905): 11ch.at is the one shared short |
| + domain every instance's "Copy link" can route through, since a |
| + dynamically-created instance host can't be listed here ahead of |
| + time. Server side: linkscmd.go (lchat links run). --> |
| + <string>applinks:11ch.at</string> |
| <!-- "Sign in with Eleven": native passkeys bind to the home server, |
| which serves the matching apple-app-site-association. --> |
| <string>webcredentials:home.elevenmessenger.com</string> |
diff --git a/linkscmd.go b/linkscmd.go| new file mode 100644 |
| index 0000000..0b3f6ec |
| --- /dev/null |
| +++ b/linkscmd.go |
| @@ -0,0 +1,254 @@ |
| +// `lchat links run` — the 11ch.at short-link redirector (issue #905). Unlike |
| +// push/home (separate Go modules, separate binaries), this is a subcommand of |
| +// the same lchat binary — there's nothing instance-specific to import, so a |
| +// second module would just be ceremony. |
| +// |
| +// No database, no registry of any kind. A bare request to the apex (someone |
| +// types or clicks plain https://11ch.at/) bounces to the marketing site. |
| +// GET /l/<host> live-checks the target — an SSRF-guarded fetch of its own |
| +// GET /api/version, cached per host — before 302-ing to it; an unreachable or |
| +// non-lchat host just 404s. This is allowlist hygiene, not a security |
| +// boundary: anyone who controls <host> can trivially pass the check by |
| +// serving genuine lchat-shaped JSON there. What actually limits abuse is that |
| +// the target is always visible in the URL itself (not a blind redirect) and |
| +// an unverifiable host never redirects at all (so this can't be driven as a |
| +// generic public URL shortener). See the design discussion on #905 for the |
| +// full reasoning, including why an earlier self-registration draft was |
| +// dropped in favor of this. |
| +// |
| +// The redirect never puts a fragment in Location, so a caller's own |
| +// #l=<token> (the message-permalink fragment, links.go) survives the hop — |
| +// browsers reattach the original request's fragment when the redirect target |
| +// doesn't specify one, verified empirically for #905. |
| +package main |
| + |
| +import ( |
| + "crypto/tls" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "os" |
| + "regexp" |
| + "sync" |
| + "time" |
| +) |
| + |
| +const ( |
| + linksVerifyFreshTTL = 7 * 24 * time.Hour // a verified host stays good this long before re-checking |
| + linksVerifyFailTTL = time.Hour // an unreachable/non-lchat host is retried sooner |
| + linksVerifyTimeout = 5 * time.Second |
| + linksMarketingURL = "https://elevenmessenger.com" |
| + linksCacheSweepAt = 10000 // sweep stale entries once the cache grows past this (mirrors modLimiter) |
| +) |
| + |
| +// validLinkHost bounds what we'll ever attempt to dial to a conservative |
| +// hostname shape — not a full RFC 1035 parser, just enough that garbage input |
| +// 404s before any network activity happens. The optional :port exists for |
| +// tests (a real instance is always plain 443, but nothing stops a self-hoster |
| +// running one behind a nonstandard port). |
| +var validLinkHost = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(:\d{1,5})?$`) |
| + |
| +// linksInsecureSkipVerifyCert lets a test stand a self-signed httptest.NewTLSServer |
| +// in for a real instance. Never set outside a test — production always |
| +// validates the candidate host's certificate. |
| +var linksInsecureSkipVerifyCert = false |
| + |
| +// newLinksVerifyClient builds the client that fetches a candidate host's |
| +// /api/version. Same SSRF guard as unfurl.go's ogClient (guardDialControl) — |
| +// a host named in a /l/ path is exactly as attacker-influenced as a |
| +// member-pasted URL. Redirects aren't followed: we only ever trust the exact |
| +// host we were asked to verify. Built fresh per call (verification is rare — |
| +// cached — so this isn't a hot path) so it picks up |
| +// linksInsecureSkipVerifyCert at call time rather than at package init. |
| +func newLinksVerifyClient() *http.Client { |
| + return &http.Client{ |
| + Timeout: linksVerifyTimeout, |
| + Transport: &http.Transport{ |
| + DialContext: (&net.Dialer{ |
| + Timeout: linksVerifyTimeout, |
| + Control: guardDialControl, |
| + }).DialContext, |
| + TLSClientConfig: &tls.Config{InsecureSkipVerify: linksInsecureSkipVerifyCert}, //nolint:gosec // test-only, see the var's doc |
| + TLSHandshakeTimeout: linksVerifyTimeout, |
| + ResponseHeaderTimeout: linksVerifyTimeout, |
| + DisableKeepAlives: true, |
| + }, |
| + CheckRedirect: func(req *http.Request, via []*http.Request) error { |
| + return http.ErrUseLastResponse |
| + }, |
| + } |
| +} |
| + |
| +// linksVerifyCache remembers whether a host looked like a live lchat instance |
| +// last time we checked, so a redirect is a fast local read on any cache hit — |
| +// bounding the live-check cost to roughly once per host per TTL window rather |
| +// than once per click (see the package doc: this is what makes the whole |
| +// design safe to run with no rate limiting beyond that). |
| +type linksVerifyCache struct { |
| + mu sync.Mutex |
| + entries map[string]linksCacheEntry |
| +} |
| + |
| +type linksCacheEntry struct { |
| + ok bool |
| + checkedAt time.Time |
| +} |
| + |
| +func newLinksVerifyCache() *linksVerifyCache { |
| + return &linksVerifyCache{entries: map[string]linksCacheEntry{}} |
| +} |
| + |
| +func (c *linksVerifyCache) verify(host string) bool { |
| + c.mu.Lock() |
| + if e, ok := c.entries[host]; ok { |
| + ttl := linksVerifyFailTTL |
| + if e.ok { |
| + ttl = linksVerifyFreshTTL |
| + } |
| + if time.Since(e.checkedAt) < ttl { |
| + c.mu.Unlock() |
| + return e.ok |
| + } |
| + } |
| + c.mu.Unlock() |
| + |
| + ok := probeLchatInstance(host) |
| + |
| + c.mu.Lock() |
| + if len(c.entries) > linksCacheSweepAt { |
| + for k, e := range c.entries { |
| + if time.Since(e.checkedAt) > linksVerifyFreshTTL { |
| + delete(c.entries, k) |
| + } |
| + } |
| + } |
| + c.entries[host] = linksCacheEntry{ok: ok, checkedAt: time.Now()} |
| + c.mu.Unlock() |
| + return ok |
| +} |
| + |
| +// probeLchatInstance does the actual SSRF-guarded fetch + shape check: does |
| +// this host answer its own /api/version with the fields a real instance always |
| +// sets (main.go's getVersion)? Weak on purpose — see the package doc on what |
| +// this can and can't defend against. |
| +func probeLchatInstance(host string) bool { |
| + req, err := http.NewRequest("GET", "https://"+host+"/api/version", nil) |
| + if err != nil { |
| + return false |
| + } |
| + req.Header.Set("User-Agent", "lchat-links/1.0 (+https://elevenmessenger.com)") |
| + resp, err := newLinksVerifyClient().Do(req) |
| + if err != nil { |
| + return false |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return false |
| + } |
| + var v struct { |
| + Build string `json:"build"` |
| + Version string `json:"version"` |
| + } |
| + if json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&v) != nil { |
| + return false |
| + } |
| + return v.Build != "" && v.Version != "" |
| +} |
| + |
| +// linksServer holds the redirector's one piece of state (the verify cache) |
| +// plus a light per-IP rate limit on cache-miss attempts — generous for real |
| +// usage, a backstop against a script hammering unverified hosts. |
| +type linksServer struct { |
| + cache *linksVerifyCache |
| + limiter *modLimiter |
| +} |
| + |
| +func newLinksServer() *linksServer { |
| + return &linksServer{cache: newLinksVerifyCache(), limiter: newModLimiter(20, 10.0/60)} |
| +} |
| + |
| +func (l *linksServer) handler() http.Handler { |
| + mux := http.NewServeMux() |
| + // Lets a tap on an 11ch.at permalink open the app directly instead of |
| + // Safari — mirrors serveAASA (main.go) for /join/*, scoped to /l/*. |
| + mux.HandleFunc("GET /.well-known/apple-app-site-association", serveLinkAASA) |
| + mux.HandleFunc("GET /l/{host}", l.serveLinkRedirect) |
| + // Everything else — most notably the bare apex — has no content of its |
| + // own here, so send it to the real marketing site. |
| + mux.HandleFunc("/", serveLinksRoot) |
| + return mux |
| +} |
| + |
| +func serveLinksRoot(w http.ResponseWriter, r *http.Request) { |
| + http.Redirect(w, r, linksMarketingURL, http.StatusFound) |
| +} |
| + |
| +func serveLinkAASA(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "application/json") |
| + fmt.Fprintf(w, `{"applinks":{"details":[{"appIDs":[%q],"components":[{"/":"/l/*"}]}]}}`, appleAppID) |
| +} |
| + |
| +func (l *linksServer) serveLinkRedirect(w http.ResponseWriter, r *http.Request) { |
| + host := r.PathValue("host") |
| + if !validLinkHost.MatchString(host) { |
| + httpError(w, http.StatusNotFound, "not found") |
| + return |
| + } |
| + ip, _, err := net.SplitHostPort(r.RemoteAddr) |
| + if err != nil { |
| + ip = r.RemoteAddr |
| + } |
| + if !l.limiter.allow(ip) { |
| + httpError(w, http.StatusTooManyRequests, "slow down") |
| + return |
| + } |
| + if !l.cache.verify(host) { |
| + httpError(w, http.StatusNotFound, "not found") |
| + return |
| + } |
| + // No fragment here — the caller's own #l=<token> (if any) reattaches |
| + // automatically; see the package doc. |
| + http.Redirect(w, r, "https://"+host+"/", http.StatusFound) |
| +} |
| + |
| +// runLinksCmd dispatches `lchat links <command>`, mirroring runBotCmd's shape |
| +// (botcmd.go) — room for a future diagnostic subcommand (e.g. `links check |
| +// <host>`) without a breaking rename of the one that matters today. |
| +func runLinksCmd(args []string) { |
| + if len(args) < 1 { |
| + fmt.Fprintln(os.Stderr, `usage: lchat links <command> |
| + |
| +commands: |
| + run serve the 11ch.at short-link redirector (issue #905)`) |
| + os.Exit(2) |
| + } |
| + switch args[0] { |
| + case "run": |
| + runLinksRun(args[1:]) |
| + default: |
| + die("links: unknown command %q", args[0]) |
| + } |
| +} |
| + |
| +// runLinksRun is `lchat links run`. |
| +func runLinksRun(args []string) { |
| + fs := flag.NewFlagSet("links", flag.ExitOnError) |
| + addr := fs.String("addr", envOr("ADDR", ":1748"), "listen address (env: ADDR)") |
| + socket := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET) — how the router fronts this service") |
| + _ = fs.Parse(args) |
| + |
| + ln, addrDesc, err := listen(*addr, *socket) |
| + if err != nil { |
| + die("links: %v", err) |
| + } |
| + l := newLinksServer() |
| + log.Printf("links: serving on %s", addrDesc) |
| + srv := &http.Server{Handler: l.handler(), ReadHeaderTimeout: 10 * time.Second} |
| + if err := srv.Serve(ln); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
diff --git a/linkscmd_test.go b/linkscmd_test.go| new file mode 100644 |
| index 0000000..79ba927 |
| --- /dev/null |
| +++ b/linkscmd_test.go |
| @@ -0,0 +1,212 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func newTestLinksServer(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + allowPrivatePreviewHosts = true // reuse the shared SSRF-guard test escape hatch |
| + linksInsecureSkipVerifyCert = true |
| + t.Cleanup(func() { |
| + allowPrivatePreviewHosts = false |
| + linksInsecureSkipVerifyCert = false |
| + }) |
| + l := newLinksServer() |
| + ts := httptest.NewServer(l.handler()) |
| + t.Cleanup(ts.Close) |
| + return ts |
| +} |
| + |
| +// fakeLchatInstance stands up an httptest TLS server that answers |
| +// /api/version like a real instance (probeLchatInstance always fetches |
| +// https://), so its shape check can pass — self-signed, hence |
| +// linksInsecureSkipVerifyCert in tests. |
| +func fakeLchatInstance(t *testing.T, build, version string) *httptest.Server { |
| + t.Helper() |
| + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/version" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]string{"build": build, "version": version}) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return ts |
| +} |
| + |
| +func noRedirectClient() *http.Client { |
| + return &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} |
| +} |
| + |
| +func TestLinksRootRedirectsToMarketingSite(t *testing.T) { |
| + ts := newTestLinksServer(t) |
| + resp, err := noRedirectClient().Get(ts.URL + "/") |
| + if err != nil { |
| + t.Fatalf("GET /: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusFound { |
| + t.Fatalf("status = %d, want 302", resp.StatusCode) |
| + } |
| + if loc := resp.Header.Get("Location"); loc != linksMarketingURL { |
| + t.Fatalf("Location = %q, want %q", loc, linksMarketingURL) |
| + } |
| +} |
| + |
| +func TestLinksArbitraryPathAlsoRedirectsToMarketingSite(t *testing.T) { |
| + ts := newTestLinksServer(t) |
| + resp, err := noRedirectClient().Get(ts.URL + "/whatever/else") |
| + if err != nil { |
| + t.Fatalf("GET: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != linksMarketingURL { |
| + t.Fatalf("status=%d location=%q", resp.StatusCode, resp.Header.Get("Location")) |
| + } |
| +} |
| + |
| +func TestLinksServesAppleAppSiteAssociation(t *testing.T) { |
| + ts := newTestLinksServer(t) |
| + resp, err := http.Get(ts.URL + "/.well-known/apple-app-site-association") |
| + if err != nil { |
| + t.Fatalf("GET aasa: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + var body struct { |
| + Applinks struct { |
| + Details []struct { |
| + AppIDs []string `json:"appIDs"` |
| + Components []struct { |
| + Path string `json:"/"` |
| + } `json:"components"` |
| + } `json:"details"` |
| + } `json:"applinks"` |
| + } |
| + if json.NewDecoder(resp.Body).Decode(&body) != nil || len(body.Applinks.Details) != 1 { |
| + t.Fatalf("unexpected aasa body") |
| + } |
| + d := body.Applinks.Details[0] |
| + if len(d.AppIDs) != 1 || d.AppIDs[0] != appleAppID { |
| + t.Fatalf("appIDs = %v, want [%q]", d.AppIDs, appleAppID) |
| + } |
| + if len(d.Components) != 1 || d.Components[0].Path != "/l/*" { |
| + t.Fatalf("components = %v, want /l/*", d.Components) |
| + } |
| +} |
| + |
| +func TestLinksRedirectsToAVerifiedInstance(t *testing.T) { |
| + ts := newTestLinksServer(t) |
| + instance := fakeLchatInstance(t, "abc123", "def456") |
| + u, _ := url.Parse(instance.URL) |
| + |
| + resp, err := noRedirectClient().Get(ts.URL + "/l/" + u.Host) |
| + if err != nil { |
| + t.Fatalf("GET /l/: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusFound { |
| + t.Fatalf("status = %d, want 302", resp.StatusCode) |
| + } |
| + want := "https://" + u.Host + "/" |
| + if loc := resp.Header.Get("Location"); loc != want { |
| + t.Fatalf("Location = %q, want %q (no fragment — the caller's own #l= must reattach on its own)", loc, want) |
| + } |
| +} |
| + |
| +func TestLinksRejectsUnverifiedHost(t *testing.T) { |
| + ts := newTestLinksServer(t) |
| + resp, err := noRedirectClient().Get(ts.URL + "/l/nothing-answers-here.invalid") |
| + if err != nil { |
| + t.Fatalf("GET: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("status = %d, want 404", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestLinksRejectsMalformedHostWithoutAnyNetworkAttempt(t *testing.T) { |
| + allowPrivatePreviewHosts = false // deliberately NOT the test escape hatch: this must 404 on shape alone |
| + l := newLinksServer() |
| + ts := httptest.NewServer(l.handler()) |
| + defer ts.Close() |
| + |
| + // "../.." isn't tested here — net/http's ServeMux cleans dot-segments out |
| + // of the path before a handler ever sees them, so it never reaches our |
| + // own validation at all (a different, earlier-stage protection). |
| + for _, bad := range []string{"not_a_host!!", "a..b", "-leading-dash", "no-dot-at-all"} { |
| + resp, err := noRedirectClient().Get(ts.URL + "/l/" + url.PathEscape(bad)) |
| + if err != nil { |
| + t.Fatalf("GET %q: %v", bad, err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("host %q: status = %d, want 404", bad, resp.StatusCode) |
| + } |
| + } |
| +} |
| + |
| +func TestLinksVerifyCacheServesFreshHitsWithoutReprobing(t *testing.T) { |
| + allowPrivatePreviewHosts = true |
| + linksInsecureSkipVerifyCert = true |
| + defer func() { |
| + allowPrivatePreviewHosts = false |
| + linksInsecureSkipVerifyCert = false |
| + }() |
| + |
| + calls := 0 |
| + instance := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + calls++ |
| + json.NewEncoder(w).Encode(map[string]string{"build": "b", "version": "v"}) |
| + })) |
| + defer instance.Close() |
| + u, _ := url.Parse(instance.URL) |
| + |
| + c := newLinksVerifyCache() |
| + if !c.verify(u.Host) { |
| + t.Fatal("first verify should succeed") |
| + } |
| + if !c.verify(u.Host) { |
| + t.Fatal("second verify (cache hit) should still report ok") |
| + } |
| + if calls != 1 { |
| + t.Fatalf("expected exactly 1 live probe (second call served from cache), got %d", calls) |
| + } |
| +} |
| + |
| +func TestLinksVerifyCacheFailEntryRetriesSoonerThanFreshEntry(t *testing.T) { |
| + c := newLinksVerifyCache() |
| + c.entries["dead.example"] = linksCacheEntry{ok: false, checkedAt: time.Now().Add(-2 * time.Hour)} |
| + c.entries["alive.example"] = linksCacheEntry{ok: true, checkedAt: time.Now().Add(-2 * time.Hour)} |
| + |
| + // Both entries are 2h stale: the fail TTL (1h) has elapsed so "dead" is due |
| + // for a re-probe, but the fresh TTL (7d) hasn't for "alive" — it should |
| + // still serve the cached true without a network attempt. |
| + allowPrivatePreviewHosts = false // no real network should happen for the fresh hit |
| + if !c.verify("alive.example") { |
| + t.Fatal("a fresh (within 7d) cached ok=true entry should serve from cache, not re-probe") |
| + } |
| +} |
| + |
| +func TestLinksRateLimiterBlocksAFlood(t *testing.T) { |
| + ts := newTestLinksServer(t) |
| + var lastStatus int |
| + for i := 0; i < 40; i++ { |
| + resp, err := noRedirectClient().Get(ts.URL + "/l/nothing-answers-here.invalid") |
| + if err != nil { |
| + t.Fatalf("GET: %v", err) |
| + } |
| + lastStatus = resp.StatusCode |
| + resp.Body.Close() |
| + if lastStatus == http.StatusTooManyRequests { |
| + return |
| + } |
| + } |
| + t.Fatalf("expected a 429 within 40 rapid requests from one IP, last status was %d", lastStatus) |
| +} |
diff --git a/main.go b/main.go| index 4e3a407..151041b 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -176,6 +176,9 @@ func main() { |
| case "bot": |
| runBotCmd(os.Args[2:]) |
| return |
| + case "links": |
| + runLinksCmd(os.Args[2:]) |
| + return |
| case "remove": |
| runRemove(os.Args[2:]) |
| return |